{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic files and structure", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f", "diff": "commit 66f89429366042c77599f3a9b8c1a7aecf976a4f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 17 23:06:17 2025 +0100\n\n    Initial commit.\n\ndiff --git a/.gitignore b/.gitignore\nnew file mode 100644\nindex 0000000..8cb2598\n--- /dev/null\n+++ b/.gitignore\n@@ -0,0 +1,166 @@\n+.vscode\n+.history\n+*.db*\n+\n+__pycache__/\n+*.py[cod]\n+*$py.class\n+\n+*.so\n+\n+.Python\n+build/\n+develop-eggs/\n+dist/\n+downloads/\n+eggs/\n+.eggs/\n+lib/\n+lib64/\n+parts/\n+sdist/\n+var/\n+wheels/\n+share/python-wheels/\n+*.egg-info/\n+.installed.cfg\n+*.egg\n+MANIFEST\n+\n+*.manifest\n+*.spec\n+\n+pip-log.txt\n+pip-delete-this-directory.txt\n+\n+htmlcov/\n+.tox/\n+.nox/\n+.coverage\n+.coverage.*\n+.cache\n+nosetests.xml\n+coverage.xml\n+*.cover\n+*.py,cover\n+.hypothesis/\n+.pytest_cache/\n+cover/\n+\n+*.mo\n+*.pot\n+\n+*.log\n+local_settings.py\n+db.sqlite3\n+db.sqlite3-journal\n+\n+instance/\n+.webassets-cache\n+\n+.scrapy\n+\n+docs/_build/\n+\n+.pybuilder/\n+target/\n+\n+.ipynb_checkpoints\n+\n+profile_default/\n+ipython_config.py\n+\n+\n+\n+\n+.pdm.toml\n+\n+__pypackages__/\n+\n+celerybeat-schedule\n+celerybeat.pid\n+\n+*.sage.py\n+\n+.env\n+.venv\n+env/\n+venv/\n+ENV/\n+env.bak/\n+venv.bak/\n+\n+.spyderproject\n+.spyproject\n+\n+.ropeproject\n+\n+/site\n+\n+.mypy_cache/\n+.dmypy.json\n+dmypy.json\n+\n+.pyre/\n+\n+.pytype/\n+\n+cython_debug/\n+\n+\ndiff --git a/Makefile b/Makefile\nnew file mode 100644\nindex 0000000..d41b81a\n--- /dev/null\n+++ b/Makefile\n@@ -0,0 +1,13 @@\n+PYTHON=./.venv/bin/python \n+PIP=./.venv/bin/pip \n+APP=./venv/bin/snek.serve\n+PORT = 8081\n+\n+\n+install:\n+\tpython3 -m venv .venv \n+\t$(PIP) install -e .\n+\n+run:\n+\t$(APP) --port=$(PORT)\n+\t\ndiff --git a/README.md b/README.md\nnew file mode 100644\nindex 0000000..b665fb1\n--- /dev/null\n+++ b/README.md\n@@ -0,0 +1,4 @@\n+\n+Matrix bot written in Python that says boeh everytime that Joe talks. He knows why.\n\\ No newline at end of file\ndiff --git a/pyproject.toml b/pyproject.toml\nnew file mode 100644\nindex 0000000..07de284\n--- /dev/null\n+++ b/pyproject.toml\n@@ -0,0 +1,3 @@\n+[build-system]\n+requires = [\"setuptools\", \"wheel\"]\n+build-backend = \"setuptools.build_meta\"\n\\ No newline at end of file\ndiff --git a/setup.cfg b/setup.cfg\nnew file mode 100644\nindex 0000000..bb6480d\n--- /dev/null\n+++ b/setup.cfg\n@@ -0,0 +1,24 @@\n+[metadata]\n+name = snek\n+version = 1.0.0\n+description = Snek chat server \n+author = retoor\n+author_email = retoor@molodetz.nl\n+license = MIT\n+long_description = file: README.md\n+long_description_content_type = text/markdown\n+\n+[options]\n+packages = find:\n+package_dir =\n+    = src\n+python_requires = >=3.7\n+install_requires =\n+\n+[options.packages.find]\n+where = src\n+\n+[options.entry_points]\n+console_scripts =\n+    snek.serve = snek.server:cli\ndiff --git a/src/snek/app.py b/src/snek/app.py\nnew file mode 100644\nindex 0000000..feb2fc0\n--- /dev/null\n+++ b/src/snek/app.py\n@@ -0,0 +1,20 @@\n+from app.app import Application as BaseApplication\n+from snek.forms import RegisterForm\n+from aiohttp import web\n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.router.add_get(\"/register\", self.handle_register)\n+        self.router.add_post(\"/register\", self.handle_register)\n+\n+    async def handle_register(self, request):\n+        if request.method == \"GET\":\n+            return web.json_response({\"form\": RegisterForm().to_json()})\n+        elif request.method == \"POST\":\n+            return self.render(\"register.html\")\n+\n+if __name__ == '__main__':\n+    app = Application()\n+    web.run_app(app,port=8081,host=\"0.0.0.0\")\ndiff --git a/src/snek/forms.py b/src/snek/forms.py\nnew file mode 100644\nindex 0000000..f4a7b24\n--- /dev/null\n+++ b/src/snek/forms.py\n@@ -0,0 +1,40 @@\n+from snek import models \n+\n+class FormElement(models.ModelField):\n+\n+    def __init__(self,html_type, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+        self.html_type = html_type \n+\n+    def to_json(self):\n+        data = super().to_json()\n+        data[\"html_type\"] = self.html_type\n+        data[\"place_holder\"] = self.place_holder\n+        return data \n+\n+class Form(models.BaseModel):\n+    pass\n+\n+class RegisterForm(Form):\n+\n+    username = FormElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        html_type=\"text\"\n+    )\n+    email = FormElement(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        html_type=\"email\"\n+    )\n+    password = FormElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",html_type=\"password\")\n+\n+\n+\ndiff --git a/src/snek/models.py b/src/snek/models.py\nnew file mode 100644\nindex 0000000..ec5d9ce\n--- /dev/null\n+++ b/src/snek/models.py\n@@ -0,0 +1,263 @@\n+import re\n+import uuid\n+import json \n+from datetime import datetime , timezone \n+from collections import OrderedDict\n+import copy \n+\n+TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n+\n+def now():\n+    return str(datetime.now(timezone.utc))\n+\n+def add_attrs(**kwargs):\n+    def decorator(func):\n+        for key, value in kwargs.items():\n+            setattr(func, key, value)\n+\n+        return func\n+    return decorator\n+\n+def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**kwargs):\n+        def decorator(func):\n+            return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n+\n+class Validator:\n+\n+    @property\n+    def value(self):\n+        return self._value \n+\n+    @value.setter \n+    def value(self,val):\n+        self._value = json.loads(json.dumps(val,default=str))\n+\n+    @property\n+    def initial_value(self):\n+        return None\n+\n+    def custom_validation(self):\n+        return True\n+\n+    def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+        self.required = required \n+        self.min_num = min_num \n+        self.max_num = max_num\n+        self.min_length = min_length \n+        self.max_length = max_length \n+        self.regex = regex \n+        self._value = None \n+        self.value = value \n+        self.type = kind\n+        self.help_text = help_text \n+        self.__dict__.update(kwargs)\n+    @property \n+    def errors(self):\n+        error_list = []\n+        if self.value is None and self.required:\n+            error_list.append(\"Field is required.\")\n+            return error_list \n+        \n+        if self.value is None:\n+            return error_list \n+\n+        if self.type == float or self.type == int:\n+            if self.min_num is not None and self.value < self.min_num:\n+                error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n+            if self.max_num is not None and self.value > self.max_num:\n+                error_list.append(\"Field should be maximal {}.\".format(self.max_num))\n+        if self.min_length is not None and len(self.value) < self.min_length:\n+            error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n+        if self.max_length is not None and len(self.value) > self.max_length:\n+            error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n+        if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\".format(self.regex))\n+        if not self.type is None and type(self.value) != self.type:\n+            error_list.append(\"Invalid type. It is supposed to be {}.\".format(self.type))\n+        return error_list \n+        \n+    def validate(self):\n+        if self.errors:\n+            raise ValueError(\"\\n\", self.errors)\n+        return True\n+\n+    @property\n+    def is_valid(self):\n+        try:\n+            self.validate()\n+            return True\n+        except ValueError:\n+            return False\n+\n+    def to_json(self):\n+        return {\n+            \"required\": self.required,\n+            \"min_num\": self.min_num,\n+            \"max_num\": self.max_num,\n+            \"min_length\": self.min_length,\n+            \"max_length\": self.max_length,\n+            \"regex\": self.regex,\n+            \"value\": self.value,\n+            \"type\": self.type,\n+            \"help_text\": self.help_text,\n+            \"errors\": self.errors,\n+            \"is_valid\": self.is_valid\n+        }\n+\n+class ModelField(Validator):\n+    def __init__(self,name=None,save=True, *args, **kwargs):\n+        self.name = name \n+        \n+        self.save = save\n+        super().__init__(*args, **kwargs)\n+\n+\n+class CreatedField(ModelField):\n+    \n+    @property\n+    def initial_value(self):\n+        return now()\n+\n+    def update(self):\n+        if not self.value:\n+            self.value = now()\n+\n+class UpdatedField(ModelField):\n+\n+    def update(self):\n+        self.value = now()\n+\n+class DeletedField(ModelField):\n+\n+    def update(self):\n+        self.value = now()\n+\n+class UUIDField(ModelField):\n+    \n+    @property \n+    def initial_value(self):\n+        return str(uuid.uuid4())\n+\n+\n+class BaseModel:\n+    \n+    uid = UUIDField(name=\"uid\",required=True)\n+    created_at = CreatedField(name=\"created_at\",required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n+    updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n+    deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+\n+    def __init__(self, *args, **kwargs):\n+        print(self.__dict__)\n+        print(dir(self.__class__))\n+        for key in dir(self.__class__):\n+            obj = getattr(self.__class__,key)\n+\n+            if isinstance(obj,Validator):\n+                self.__dict__[key] = copy.deepcopy(obj)\n+                print(\"JAAA\")\n+                self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+\n+    def __setitem__(self, key, value):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            obj.value = value \n+\n+    def __getattr__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            print(\"HPAPP\")\n+            return obj.value \n+        return obj\n+\n+\n+    def __getitem__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            return obj.value \n+\n+    def __setattr__(self, key, value):\n+        obj = getattr(self,key)\n+        if isinstance(obj,Validator):\n+            obj.value = value\n+        else:\n+            setattr(self,key,value)\n+    @property \n+    def record(self):\n+        obj = self.to_json()\n+        record = {}\n+        for key,value in obj.items():\n+            if getattr(self,key).save:\n+                record[key] = value.get('value')\n+        return record\n+\n+    def to_json(self,encode=False):\n+        model_data = OrderedDict({\n+            \"uid\": self.uid.value,\n+            \"created_at\": self.created_at.value,\n+            \"updated_at\": self.updated_at.value,\n+            \"deleted_at\": self.deleted_at.value\n+        })\n+        for key,value in self.__dict__.items(): \n+            if key == \"record\":\n+                continue\n+            value = self.__dict__[key]\n+            if hasattr(value,\"value\"):\n+                model_data[key] = value.to_json()\n+        if encode:\n+            return json.dumps(model_data,indent=2)\n+        return model_data\n+\n+class FormElement(ModelField):\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+\n+\n+\n+class FormElement(ModelField):\n+\n+    def __init__(self,place_holder=None, *args, **kwargs): \n+        self.place_holder = place_holder \n+        super().__init__(*args, **kwargs)\n+\n+\n+    def to_json(self):\n+        data = super().to_json()\n+        data[\"name\"] = self.name \n+        data[\"place_holder\"] = self.place_holder\n+        return data \n+\n+\n+\n+\n+class TestModel(BaseModel):\n+\n+    first_name = FormElement(name=\"first_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"First name\")\n+    last_name = FormElement(name=\"last_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Last name\")\n+    email = FormElement(name=\"email\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")  \n+\n+class Form:\n+    username = FormElement(required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Username\")\n+    email = FormElement(required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")\n+    def __init__(self, *args, **kwargs):\n+        self.place_holder = kwargs.pop(\"place_holder\",None) \n+\n+\n+if __name__ == \"__main__\":\n+    model = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"n9K9p@example.com\",password=\"Password123\")\n+    model2 = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"ddd\",password=\"zzz\")\n+    model.first_name = \"AAA\"\n+    print(model.first_name)\n+    print(model.first_name.value)\n+    \n+    print(model.first_name)\n+    print(model.first_name.value)\n+    print(model.to_json(True))\n+    print(model2.to_json(True))\n+    print(model2.record)"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with setup and basic instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62", "diff": "commit 46a27405aeb8ec426fd1c686a2c090f9fe9c0e62\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 17 23:09:46 2025 +0100\n\n    Initial commit.\n\ndiff --git a/README.md b/README.md\nindex b665fb1..df001ee 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,10 @@\n \n-Matrix bot written in Python that says boeh everytime that Joe talks. He knows why.\n\\ No newline at end of file\n+This is a slack like chat application with focus on performance. Other slack-like applications became too heavy. My RocketChat just had an 'frontend' crash. Just an error from the system itself like it's the most normal thing in the world.\n+\n+At this point, there's nothing officially running but what you can do:\n+* Install the project: `make install`\n+* Run part of the project: `./venv/bin/python -m snek.app`\n+* Run other part of the project: `./venv/bin/python -m snek.models`"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added Dockerfile, Makefile, and compose.yml for containerization.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0", "diff": "commit a7446d131413da9f013a56d3541192d8ab1e22b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 18 13:21:38 2025 +0100\n\n    Progress.\n\ndiff --git a/.gitignore b/.gitignore\nindex 8cb2598..3747073 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,7 +1,7 @@\n .vscode\n .history\n *.db*\n-\n+*.png\n __pycache__/\ndiff --git a/Dockerfile b/Dockerfile\nnew file mode 100644\nindex 0000000..76436ba\n--- /dev/null\n+++ b/Dockerfile\n@@ -0,0 +1,40 @@\n+FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n+FROM python:3.10-alpine\n+WORKDIR /code\n+ENV FLASK_APP=app.py\n+ENV FLASK_RUN_HOST=0.0.0.0\n+RUN apk add --no-cache gcc musl-dev linux-headers git\n+\n+RUN apk add --no-cache \\\n+    libstdc++ \\\n+    libx11 \\\n+    libxrender \\\n+    libxext \\\n+    libssl3 \\\n+    ca-certificates \\\n+    fontconfig \\\n+    freetype \\\n+    ttf-dejavu \\\n+    ttf-droid \\\n+    ttf-freefont \\\n+    ttf-liberation \\\n+  && apk add --no-cache --virtual .build-deps \\\n+    msttcorefonts-installer \\\n+  && update-ms-fonts \\\n+  && fc-cache -f \\\n+  && apk del .build-deps\n+COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n+COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n+COPY setup.cfg setup.cfg \n+COPY pyproject.toml pyproject.toml \n+COPY src src\n+RUN pip install --upgrade pip\n+RUN pip install -e .\n+EXPOSE 8081\n+\n+CMD [\"gunicorn\", \"-w\", \"10\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\ndiff --git a/Makefile b/Makefile\nindex d41b81a..65c58fa 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1,6 +1,8 @@\n PYTHON=./.venv/bin/python \n PIP=./.venv/bin/pip \n-APP=./venv/bin/snek.serve\n+APP=./.venv/bin/snek.serve\n+GUNICORN=./.venv/bin/gunicorn\n+GUNICORN_WORKERS = 1\n PORT = 8081\n \n \n@@ -9,5 +11,5 @@ install:\n \t$(PIP) install -e .\n \n run:\n-\t$(APP) --port=$(PORT)\n+\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/cache/crc321300331366.cache b/cache/crc321300331366.cache\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/cache/crc322507170282.cache b/cache/crc322507170282.cache\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/compose.yml b/compose.yml\nnew file mode 100644\nindex 0000000..9186108\n--- /dev/null\n+++ b/compose.yml\n@@ -0,0 +1,12 @@\n+services:\n+  snek:\n+    build: .\n+    ports:\n+      - \"8081:8081\"\n+    volumes:\n+      - ./:/code\n+    develop:\n+      watch:\n+        - action: sync\n+          path: .\n+          target: /code\n\\ No newline at end of file\ndiff --git a/setup.cfg b/setup.cfg\nindex bb6480d..ca8353d 100644\n--- a/setup.cfg\n+++ b/setup.cfg\n@@ -15,6 +15,10 @@ package_dir =\n python_requires = >=3.7\n install_requires =\n+    beautifulsoup4\n+    gunicorn\n+    imgkit\n+    wkhtmltopdf\n \n [options.packages.find]\n where = src\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex feb2fc0..97fbb96 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,20 +1,60 @@\n from app.app import Application as BaseApplication\n from snek.forms import RegisterForm\n from aiohttp import web\n+import aiohttp \n+import pathlib\n+from snek import http \n+from snek.middleware import cors_allow_middleware,cors_middleware\n+\n \n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n+        middlewares = [\n+            cors_middleware,\n+            web.normalize_path_middleware(merge_slashes=True)\n+        ]\n+        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        super().__init__(middlewares=middlewares, template_path=self.template_path,*args, **kwargs)\n+        self.router.add_static(\"/\",pathlib.Path(__file__).parent.joinpath(\"static\"),name=\"static\",show_index=True)\n         self.router.add_get(\"/register\", self.handle_register)\n+        self.router.add_get(\"/login\", self.handle_login)\n+        self.router.add_get(\"/test\", self.handle_test)\n         self.router.add_post(\"/register\", self.handle_register)\n+        self.router.add_get(\"/http-get\",self.handle_http_get)\n+        self.router.add_get(\"/http-photo\",self.handle_http_photo)\n+\n+    async def handle_test(self,request):\n+\n+        return await self.render_template(\"test.html\",request,context={\"name\":\"retoor\"})\n+\n+    async def handle_http_get(self, request:web.Request):\n+        url = request.query.get(\"url\")\n+        content = await http.get(url)\n+        return web.Response(body=content)\n+\n+    async def handle_http_photo(self, request):\n+        url = request.query.get(\"url\")\n+        path = await http.create_site_photo(url)\n+        return web.Response(body=path.read_bytes(),headers={\n+            \"Content-Type\": \"image/png\"\n+        })\n+\n+    async def handle_login(self, request):\n+        if request.method == \"GET\":\n+        elif request.method == \"POST\":\n+        \n \n     async def handle_register(self, request):\n         if request.method == \"GET\":\n-            return web.json_response({\"form\": RegisterForm().to_json()})\n         elif request.method == \"POST\":\n             return self.render(\"register.html\")\n \n+app = Application()\n+\n if __name__ == '__main__':\n-    app = Application()\n+\n     web.run_app(app,port=8081,host=\"0.0.0.0\")\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nnew file mode 100644\nindex 0000000..4055bbd\n--- /dev/null\n+++ b/src/snek/gunicorn.py\n@@ -0,0 +1,3 @@\n+from snek.app import app \n+\n+application = app\ndiff --git a/src/snek/http.py b/src/snek/http.py\nnew file mode 100644\nindex 0000000..0b16bee\n--- /dev/null\n+++ b/src/snek/http.py\n@@ -0,0 +1,83 @@\n+from aiohttp import web \n+import aiohttp \n+from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n+from urllib.parse import urljoin\n+import pathlib \n+import uuid \n+import imgkit \n+import asyncio\n+import zlib\n+import io \n+\n+async def crc32(data):\n+    try:\n+        data = data.encode()\n+    except:\n+        pass \n+    result = \"crc32\" + str(zlib.crc32(data))\n+    return result \n+\n+async def get_file(name,suffix=\".cache\"):\n+    name = await crc32(name)\n+    path = pathlib.Path(\".\").joinpath(\"cache\")\n+    if not path.exists():\n+        path.mkdir(parents=True,exist_ok=True)\n+    path = path.joinpath(name + suffix)\n+    return path\n+\n+\n+\n+async def public_touch(name=None):\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4())+name)\n+    path.open(\"wb\").close()\n+    return path \n+\n+async def create_site_photo(url):\n+    loop = asyncio.get_event_loop()\n+    if not url.startswith(\"https\"):\n+    output_path = await get_file(\"site-screenshot-\" + url,\".png\")\n+    \n+    if output_path.exists():\n+        return output_path\n+    output_path.touch()\n+    def make_photo():\n+        imgkit.from_url(url, output_path.absolute())\n+        return output_path \n+\n+    return await loop.run_in_executor(None,make_photo)\n+\n+async def repair_links(base_url, html_content):\n+    soup = BeautifulSoup(html_content, \"html.parser\")\n+    for tag in soup.find_all(['a', 'img', 'link']):\n+            tag['href'] = urljoin(base_url, tag['href'])\n+            tag['src'] = urljoin(base_url, tag['src'])\n+            print(\"Fixed: \",tag['src'])\n+    return soup.prettify()\n+\n+async def is_html_content(content: bytes):\n+    try:\n+        content = content.decode(errors='ignore')\n+    except:\n+        pass \n+    marks = ['<html','<img','<p','<span','<div']\n+    try:\n+        content = content.lower()\n+        for mark in marks:\n+            if mark in content:\n+                return True \n+    except Exception as ex:\n+        print(ex)\n+    return False \n+\n+@time_cache_async(120)\n+async def get(url):\n+    async with aiohttp.ClientSession() as session:\n+        response = await session.get(url)\n+        content = await response.text()\n+        if await is_html_content(content):\n+            content = (await repair_links(url,content)).encode()\n+        return content\n\\ No newline at end of file\ndiff --git a/src/snek/middleware.py b/src/snek/middleware.py\nnew file mode 100644\nindex 0000000..6b801ab\n--- /dev/null\n+++ b/src/snek/middleware.py\n@@ -0,0 +1,32 @@\n+from aiohttp import web \n+\n+@web.middleware\n+async def no_cors_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n+    return response\n+\n+@web.middleware\n+async def cors_allow_middleware(request ,handler):\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    return response \n+\n+@web.middleware\n+async def cors_middleware(request, handler):\n+    if request.method == \"OPTIONS\":\n+        response = web.Response()\n+        response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+        response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+        response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+        return response\n+\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    return response\n\\ No newline at end of file\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nnew file mode 100644\nindex 0000000..701beda\n--- /dev/null\n+++ b/src/snek/static/app.js\n@@ -0,0 +1,77 @@\n+\n+\n+class Message {\n+    uid = null \n+    author = null\n+    avatar = null \n+    text = null \n+    time = null\n+    constructor(uid,avatar,author,text,time){\n+        this.uid = uid \n+        this.avatar = avatar \n+        this.author = author \n+        this.text = text \n+        this.time = time \n+    }\n+    \n+    get links() {\n+        if(!this.text)\n+            return []\n+        let result = []\n+        for(let part in this.text.split(/[,; ]/)){\n+            if(part.startsWith(\"http\") || part.startsWith(\"www.\") || part.indexOf(\".com\") || part.indexOf(\".net\") || part.indexOf(\".io\") || part.indexOf(\".nl\")){\n+                result.push(part)\n+\n+            }\n+        }\n+        return result\n+    }\n+    get mentions() {\n+        if(!this.text)\n+            return []\n+        let result = []\n+        for(let part in this.text.split(/[,; ]/)){\n+            if(part.startsWith(\"@\")){\n+                result.push(part)\n+\n+            }\n+        }\n+        return result \n+    }\n+}\n+\n+\n+class Messages {\n+\n+\n+\n+}\n+\n+\n+\n+\n+class Room {\n+    name = null \n+    messages = []\n+    constructor(name){\n+        this.name = name \n+    }\n+    setMessages(list){\n+        \n+    }\n+\n+\n+}\n+\n+\n+class App {\n+    rooms = []\n+    constructor() {\n+        this.rooms.push(new Room(\"General\"))\n+\n+\n+    }\n+\n+\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.css b/src/snek/static/html_frame.css\nnew file mode 100644\nindex 0000000..6b64c76\n--- /dev/null\n+++ b/src/snek/static/html_frame.css\n@@ -0,0 +1,9 @@\n+.html-frame {\n+    width: 100px;\n+    height: 50px;\n+    position: relative;\n+    overflow: hidden;\n+    border: 1px solid black;\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.js b/src/snek/static/html_frame.js\nnew file mode 100644\nindex 0000000..19a0c34\n--- /dev/null\n+++ b/src/snek/static/html_frame.js\n@@ -0,0 +1,39 @@\n+class HTMLFrame extends HTMLElement {\n+    constructor() {\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.container = document.createElement('div');\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+        this.container.classList.add(\"html_frame\")\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)\n+        console.info(fullUrl)    \n+        this.fetchAndDisplayHtml(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No URL provided!\";\n+      }\n+    }\n+\n+    async fetchAndDisplayHtml(url) {\n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+        }\n+        const html = await response.text();\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+  }\n+\n+  customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/prachtig-gitter_like.html b/src/snek/static/prachtig-gitter_like.html\nnew file mode 100644\nindex 0000000..bb30554\n--- /dev/null\n+++ b/src/snek/static/prachtig-gitter_like.html\n@@ -0,0 +1,172 @@\n+* {\n+  margin: 0;\n+  padding: 0;\n+  box-sizing: border-box;\n+}\n+\n+body {\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+}\n+\n+header {\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n+}\n+\n+header .logo {\n+  font-size: 1.5em;\n+  font-weight: bold;\n+}\n+\n+header nav a {\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+header nav a:hover {\n+}\n+\n+main {\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n+}\n+\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+\n+.chat-area {\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+}\n+\n+.chat-header {\n+  padding: 10px 20px;\n+}\n+\n+.chat-header h2 {\n+  font-size: 1.2em;\n+}\n+\n+.chat-messages {\n+  flex: 1;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.chat-messages .message {\n+  margin-bottom: 15px;\n+}\n+\n+.chat-messages .message .author {\n+  font-weight: bold;\n+}\n+\n+.chat-messages .message .content {\n+  margin-left: 10px;\n+}\n+\n+.chat-input {\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n+}\n+\n+.chat-input textarea {\n+  flex: 1;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n+.chat-input button {\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n+}\n+\n+.chat-input button:hover {\n+}\n+\n+@media (max-width: 768px) {\n+  .sidebar {\n+    display: none;\n+  }\n+\n+  .chat-area {\n+    flex: 1;\n+  }\n+}\n+\ndiff --git a/src/snek/static/register.css b/src/snek/static/register.css\nnew file mode 100644\nindex 0000000..fc4ca0f\n--- /dev/null\n+++ b/src/snek/static/register.css\n@@ -0,0 +1,95 @@\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+  \n+  body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    height: 100vh;\n+  }\n+  \n+  .registration-container {\n+    border-radius: 10px;\n+    padding: 30px;\n+    width: 400px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+    text-align: center;\n+  }\n+  \n+  .registration-container h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+  }\n+  \n+  .registration-container input {\n+    width: 100%;\n+    padding: 10px;\n+    margin: 10px 0;\n+    border-radius: 5px;\n+    font-size: 1em;\n+  }\n+  \n+  .registration-container button {\n+    width: 100%;\n+    padding: 10px;\n+    border: none;\n+    border-radius: 5px;\n+    color: white;\n+    font-size: 1em;\n+    font-weight: bold;\n+    cursor: pointer;\n+    transition: background-color 0.3s;\n+  }\n+  \n+  .registration-container button:hover {\n+  }\n+  \n+  .registration-container a {\n+    text-decoration: none;\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  \n+  .registration-container a:hover {\n+  }\n+  \n+  .error {\n+    font-size: 0.9em;\n+    margin-top: 5px;\n+  }\n+  \n+  @media (max-width: 500px) {\n+    .registration-container {\n+      width: 90%;\n+    }\n+  }\n+  \n\\ No newline at end of file\ndiff --git a/src/snek/static/styles.css b/src/snek/static/styles.css\nnew file mode 100644\nindex 0000000..83c1fb1\n--- /dev/null\n+++ b/src/snek/static/styles.css\n@@ -0,0 +1,203 @@\n+* {\n+  margin: 0;\n+  padding: 0;\n+  box-sizing: border-box;\n+}\n+\n+body {\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+}\n+\n+header {\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n+}\n+\n+header .logo {\n+  font-size: 1.5em;\n+  font-weight: bold;\n+}\n+\n+header nav a {\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+header nav a:hover {\n+}\n+\n+main {\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n+}\n+\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+\n+.chat-area {\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+}\n+\n+.chat-header {\n+  padding: 10px 20px;\n+}\n+\n+.chat-header h2 {\n+  font-size: 1.2em;\n+}\n+\n+.chat-messages {\n+  flex: 1;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.chat-messages .message {\n+  display: flex;\n+  align-items: flex-start;\n+  margin-bottom: 15px;\n+  padding: 10px;\n+  border-radius: 8px;\n+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);\n+}\n+\n+.chat-messages .message .avatar {\n+  width: 40px;\n+  height: 40px;\n+  border-radius: 50%;\n+  font-size: 1em;\n+  font-weight: bold;\n+  display: flex;\n+  justify-content: center;\n+  align-items: center;\n+  margin-right: 15px;\n+}\n+\n+.chat-messages .message .message-content {\n+  flex: 1;\n+}\n+\n+.chat-messages .message .message-content .author {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+}\n+\n+.chat-messages .message .message-content .text {\n+  margin-bottom: 5px;\n+}\n+\n+.chat-messages .message .message-content .time {\n+  font-size: 0.8em;\n+}\n+\n+.chat-input {\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n+}\n+\n+.chat-input textarea {\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n+.chat-input button {\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n+}\n+\n+.chat-input button:hover {\n+}\n+\n+@media (max-width: 768px) {\n+  .sidebar {\n+    display: none;\n+  }\n+\n+  .chat-area {\n+    flex: 1;\n+  }\n+}\n+\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nnew file mode 100644\nindex 0000000..d37f3fa\n--- /dev/null\n+++ b/src/snek/templates/login.html\n@@ -0,0 +1,20 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Register</title>\n+  <link rel=\"stylesheet\" href=\"register.css\">\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Login</h1>\n+    <form>\n+      <input type=\"text\" name=\"username\" placeholder=\"Username or password\" required>\n+      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n+      <button type=\"submit\">Create Account</button>\n+      <a href=\"/register\">Not having an account yet? Register here.</a>\n+    </form>\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/prachtig_gitter_like.html b/src/snek/templates/prachtig_gitter_like.html\nnew file mode 100644\nindex 0000000..cd2d863\n--- /dev/null\n+++ b/src/snek/templates/prachtig_gitter_like.html\n@@ -0,0 +1,51 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dark Themed Chat Application</title>\n+  <link rel=\"stylesheet\" href=\"styles.css\">\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Molodetz Chat</div>\n+    <nav>\n+    </nav>\n+  </header>\n+  <main>\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+      </ul>\n+    </aside>\n+    <section class=\"chat-area\">\n+      <div class=\"chat-header\">\n+        <h2>General</h2>\n+      </div>\n+      <div class=\"chat-messages\">\n+        <div class=\"message\">\n+          <span class=\"author\">Alice:</span>\n+          <span class=\"content\">Hello, everyone!</span>\n+        </div>\n+        <div class=\"message\">\n+          <span class=\"author\">Bob:</span>\n+          <span class=\"content\">Hi Alice! How are you?</span>\n+        </div>\n+      </div>\n+      <div class=\"chat-input\">\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <button>Send</button>\n+      </div>\n+    </section>\n+  </main>\n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nnew file mode 100644\nindex 0000000..da41629\n--- /dev/null\n+++ b/src/snek/templates/register.html\n@@ -0,0 +1,22 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Register</title>\n+  <link rel=\"stylesheet\" href=\"register.css\">\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Register</h1>\n+    <form>\n+      <input type=\"text\" name=\"username\" placeholder=\"Username\" required>\n+      <input type=\"email\" name=\"email\" placeholder=\"Email Address\" required>\n+      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n+      <input type=\"password\" name=\"confirm_password\" placeholder=\"Confirm Password\" required>\n+      <button type=\"submit\">Create Account</button>\n+    </form>\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/test.html b/src/snek/templates/test.html\nnew file mode 100644\nindex 0000000..c23103a\n--- /dev/null\n+++ b/src/snek/templates/test.html\n@@ -0,0 +1,63 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dark Themed Chat Application</title>\n+  <link rel=\"stylesheet\" href=\"styles.css\">\n+  <script src=\"/html_frame.js\"></script>\n+  <script src=\"/html_frame.css\"></script>\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Molodetz Chat</div>\n+    <nav>\n+    </nav>\n+  </header>\n+  <main>\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+      </ul>\n+    </aside>\n+    <section class=\"chat-area\">\n+      <div class=\"chat-header\">\n+        <h2>General</h2>\n+      </div>\n+      <div class=\"chat-messages\">\n+        <div class=\"message\">\n+          <div class=\"avatar\">A</div>\n+          <div class=\"message-content\">\n+            <div class=\"author\">Alice</div>\n+            <div class=\"text\">Hello, everyone!</div>\n+            <div class=\"time\">10:45 AM</div>\n+          </div>\n+        </div>\n+        <html-frame class=\"html-frame\" url=\"/register\"></html-frame>\n+        <div class=\"message\">\n+          <div class=\"avatar\">B</div>\n+          <div class=\"message-content\">\n+            <div class=\"author\">Bob</div>\n+            <div class=\"text\">Hi Alice! How are you?</div>\n+            <div class=\"time\">10:46 AM</div>\n+          </div>\n+        </div>\n+      </div>\n+      <div class=\"chat-input\">\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <button>Send</button>\n+      </div>\n+    </section>\n+  </main>\n+  \n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/test2.html b/src/snek/templates/test2.html\nnew file mode 100644\nindex 0000000..9aad83a\n--- /dev/null\n+++ b/src/snek/templates/test2.html\n@@ -0,0 +1,122 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dynamic Form Component</title>\n+  <style>\n+    .form-container {\n+      max-width: 400px;\n+      margin: 20px auto;\n+      padding: 20px;\n+      border-radius: 5px;\n+    }\n+    .form-field {\n+      margin-bottom: 15px;\n+    }\n+    .form-field label {\n+      font-weight: bold;\n+      display: block;\n+      margin-bottom: 5px;\n+    }\n+    .form-field input {\n+      width: 100%;\n+      padding: 8px;\n+      box-sizing: border-box;\n+      border-radius: 3px;\n+    }\n+    .form-field .error {\n+      color: red;\n+      font-size: 0.9em;\n+      margin-top: 5px;\n+    }\n+  </style>\n+</head>\n+<body>\n+  <!-- Use the custom form component -->\n+  <dynamic-form></dynamic-form>\n+\n+  <script>\n+    class DynamicForm extends HTMLElement {\n+      constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+      }\n+\n+      connectedCallback() {\n+        const formData = {\n+          form: {\n+            uid: { required: true, value: \"e13ad3b7-20b2-4c1a-b74e-b8c7d1abd107\", html_type: \"text\", place_holder: \"UID\", is_valid: true },\n+            created_at: { required: true, value: \"2025-01-17 21:21:27.561769+00:00\", html_type: \"text\", place_holder: \"Created At\", is_valid: true },\n+            updated_at: { required: false, value: null, html_type: \"text\", place_holder: \"Updated At\", is_valid: true },\n+            deleted_at: { required: false, value: null, html_type: \"text\", place_holder: \"Deleted At\", is_valid: true },\n+            email: { required: true, value: null, html_type: \"email\", place_holder: \"Email address\", errors: [\"Field is required.\"], is_valid: false },\n+            password: { required: true, value: null, html_type: \"password\", place_holder: \"Password\", errors: [\"Field is required.\"], is_valid: false },\n+            username: { required: true, value: null, html_type: \"text\", place_holder: \"Username\", errors: [\"Field is required.\"], is_valid: false }\n+          }\n+        };\n+\n+        this.render(formData);\n+      }\n+\n+      render(data) {\n+        const form = data.form;\n+        const container = document.createElement('div');\n+        container.className = 'form-container';\n+\n+        const formElement = document.createElement('form');\n+\n+        Object.entries(form).forEach(([fieldName, fieldData]) => {\n+          const fieldContainer = document.createElement('div');\n+          fieldContainer.className = 'form-field';\n+\n+          const label = document.createElement('label');\n+          label.textContent = fieldName.replace(/_/g, ' ').toUpperCase();\n+          label.htmlFor = fieldName;\n+          fieldContainer.appendChild(label);\n+\n+          const input = document.createElement('input');\n+          input.type = fieldData.html_type || 'text';\n+          input.name = fieldName;\n+          input.value = fieldData.value || '';\n+          input.placeholder = fieldData.place_holder || '';\n+          input.required = fieldData.required || false;\n+\n+          fieldContainer.appendChild(input);\n+\n+          if (fieldData.errors && fieldData.errors.length > 0) {\n+            const errorDiv = document.createElement('div');\n+            errorDiv.className = 'error';\n+            errorDiv.textContent = fieldData.errors.join(', ');\n+            fieldContainer.appendChild(errorDiv);\n+          }\n+\n+          formElement.appendChild(fieldContainer);\n+        });\n+\n+        container.appendChild(formElement);\n+\n+        this.shadowRoot.appendChild(container);\n+      }\n+    }\n+\n+    customElements.define('dynamic-form', DynamicForm);\n+  </script>\n+</body>\n+</html>\n+"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a", "diff": "commit 2e3b85d7f739160783e7c5552f1306298047704a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 18 12:23:23 2025 +0000\n\n    Updated compose.yml\n\ndiff --git a/compose.yml b/compose.yml\nindex 9186108..776e60d 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -1,12 +1,8 @@\n services:\n   snek:\n     build: .\n+    restart: always\n     ports:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-    develop:\n-      watch:\n-        - action: sync\n-          path: .\n-          target: /code\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and update dependencies.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81", "diff": "commit ba83922660dade77dcb96e8ba9c73cfcba8c2b81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 03:28:43 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/prachtig-gitter_like.html b/.resources/prachtig-gitter_like.html\nsimilarity index 100%\nrename from src/snek/static/prachtig-gitter_like.html\nrename to .resources/prachtig-gitter_like.html\ndiff --git a/src/snek/templates/prachtig_gitter_like.html b/.resources/prachtig_gitter_like.html\nsimilarity index 100%\nrename from src/snek/templates/prachtig_gitter_like.html\nrename to .resources/prachtig_gitter_like.html\ndiff --git a/Dockerfile b/Dockerfile\nindex 76436ba..47c0ece 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -37,4 +37,5 @@ RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 8081\n \n-CMD [\"gunicorn\", \"-w\", \"10\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+python -m snek.app\ndiff --git a/LICENSE.txt b/LICENSE.txt\nnew file mode 100644\nindex 0000000..6b0da88\n--- /dev/null\n+++ b/LICENSE.txt\n@@ -0,0 +1,21 @@\n+MIT License\n+\n+Copyright (c) 2025 retoor\n+\n+Permission is hereby granted, free of charge, to any person obtaining a copy\n+of this software and associated documentation files (the \"Software\"), to deal\n+in the Software without restriction, including without limitation the rights\n+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n+copies of the Software, and to permit persons to whom the Software is\n+furnished to do so, subject to the following conditions:\n+\n+The above copyright notice and this permission notice shall be included in all\n+copies or substantial portions of the Software.\n+\n+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n+SOFTWARE.\ndiff --git a/Makefile b/Makefile\nindex 65c58fa..f153fc1 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -10,6 +10,8 @@ install:\n \tpython3 -m venv .venv \n \t$(PIP) install -e .\n \n+\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/compose.yml b/compose.yml\nindex 776e60d..3b1f650 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,3 +6,5 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+   \n\\ No newline at end of file\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 07de284..d98557e 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,3 +1,25 @@\n [build-system]\n requires = [\"setuptools\", \"wheel\"]\n-build-backend = \"setuptools.build_meta\"\n\\ No newline at end of file\n+build-backend = \"setuptools.build_meta\"\n+\n+[project]\n+name = \"Snek\"\n+version = \"1.0.0\"\n+readme = \"README.md\"\n+license = { file = \"LICENSE\", content-type=\"text/markdown\" }\n+description = \"Snek Chat Application by Molodetz\"\n+authors = [\n+    { name = \"retoor\", email = \"retoor@molodetz.nl\" }\n+]\n+keywords = [\"chat\", \"snek\", \"molodetz\"]\n+requires-python = \">=3.12\"\n+dependencies = [\n+    \"mkdocs>=1.4.0\",\n+    \"shed\",\n+    \"beautifulsoup4\",\n+    \"gunicorn\",\n+    \"imgkit\",\n+    \"wkhtmltopdf\"\n+]\n+\ndiff --git a/setup.cfg b/setup.cfg\nindex ca8353d..045fc92 100644\n--- a/setup.cfg\n+++ b/setup.cfg\n@@ -19,6 +19,7 @@ install_requires =\n     gunicorn\n     imgkit\n     wkhtmltopdf\n+    shed\n \n [options.packages.find]\n where = src\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 97fbb96..0e2ed63 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,10 +1,16 @@\n-from app.app import Application as BaseApplication\n-from snek.forms import RegisterForm\n-from aiohttp import web\n-import aiohttp \n import pathlib\n-from snek import http \n-from snek.middleware import cors_allow_middleware,cors_middleware\n+\n+from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n+from snek.system import http\n+from snek.system.middleware import cors_middleware\n+from snek.view.index import IndexView\n+from snek.view.login import LoginView\n+from snek.view.login_form import LoginFormView\n+from snek.view.register import RegisterView\n+from snek.view.register_form import RegisterFormView\n+from snek.view.view import WebView\n \n \n class Application(BaseApplication):\n@@ -12,23 +18,38 @@ class Application(BaseApplication):\n     def __init__(self, *args, **kwargs):\n         middlewares = [\n             cors_middleware,\n-            web.normalize_path_middleware(merge_slashes=True)\n+            web.normalize_path_middleware(merge_slashes=True),\n         ]\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n-        super().__init__(middlewares=middlewares, template_path=self.template_path,*args, **kwargs)\n-        self.router.add_static(\"/\",pathlib.Path(__file__).parent.joinpath(\"static\"),name=\"static\",show_index=True)\n-        self.router.add_get(\"/register\", self.handle_register)\n-        self.router.add_get(\"/login\", self.handle_login)\n-        self.router.add_get(\"/test\", self.handle_test)\n-        self.router.add_post(\"/register\", self.handle_register)\n-        self.router.add_get(\"/http-get\",self.handle_http_get)\n-        self.router.add_get(\"/http-photo\",self.handle_http_photo)\n+        super().__init__(\n+            middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n+        )\n+        self.setup_router()\n+\n+    def setup_router(self):\n+        self.router.add_get(\"/\", IndexView)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n+        self.router.add_view(\"/web\", WebView)\n+        self.router.add_view(\"/login\", LoginView)\n+        self.router.add_view(\"/login-form\", LoginFormView)\n+        self.router.add_view(\"/register\", RegisterView)\n+        \n+        self.router.add_view(\"/register-form\", RegisterFormView)\n+        self.router.add_get(\"/http-get\", self.handle_http_get)\n+        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n-    async def handle_test(self,request):\n+    async def handle_test(self, request):\n \n-        return await self.render_template(\"test.html\",request,context={\"name\":\"retoor\"})\n+        return await self.render_template(\n+            \"test.html\", request, context={\"name\": \"retoor\"}\n+        )\n \n-    async def handle_http_get(self, request:web.Request):\n+    async def handle_http_get(self, request: web.Request):\n         url = request.query.get(\"url\")\n         content = await http.get(url)\n         return web.Response(body=content)\n@@ -36,25 +57,13 @@ class Application(BaseApplication):\n     async def handle_http_photo(self, request):\n         url = request.query.get(\"url\")\n         path = await http.create_site_photo(url)\n-        return web.Response(body=path.read_bytes(),headers={\n-            \"Content-Type\": \"image/png\"\n-        })\n-\n-    async def handle_login(self, request):\n-        if request.method == \"GET\":\n-        elif request.method == \"POST\":\n-        \n+        return web.Response(\n+            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n+        )\n \n-    async def handle_register(self, request):\n-        if request.method == \"GET\":\n-        elif request.method == \"POST\":\n-            return self.render(\"register.html\")\n \n app = Application()\n \n-if __name__ == '__main__':\n+if __name__ == \"__main__\":\n \n-    web.run_app(app,port=8081,host=\"0.0.0.0\")\n+    web.run_app(app, port=8081, host=\"0.0.0.0\")\ndiff --git a/src/snek/form/__init__.py b/src/snek/form/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nnew file mode 100644\nindex 0000000..a87f7d3\n--- /dev/null\n+++ b/src/snek/form/login.py\n@@ -0,0 +1,24 @@\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class LoginForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Login\")\n+\n+    username = FormInputElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n+\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Login\",\n+        type=\"button\"\n+    )\n+\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nnew file mode 100644\nindex 0000000..60399fb\n--- /dev/null\n+++ b/src/snek/form/register.py\n@@ -0,0 +1,31 @@\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = FormInputElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n+\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+\ndiff --git a/src/snek/forms.py b/src/snek/forms.py\ndeleted file mode 100644\nindex f4a7b24..0000000\n--- a/src/snek/forms.py\n+++ /dev/null\n@@ -1,40 +0,0 @@\n-from snek import models \n-\n-class FormElement(models.ModelField):\n-\n-    def __init__(self,html_type, place_holder=None, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n-        self.place_holder = place_holder\n-        self.html_type = html_type \n-\n-    def to_json(self):\n-        data = super().to_json()\n-        data[\"html_type\"] = self.html_type\n-        data[\"place_holder\"] = self.place_holder\n-        return data \n-\n-class Form(models.BaseModel):\n-    pass\n-\n-class RegisterForm(Form):\n-\n-    username = FormElement(\n-        name=\"username\", \n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n-        place_holder=\"Username\",\n-        html_type=\"text\"\n-    )\n-    email = FormElement(\n-        name=\"email\",\n-        required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        html_type=\"email\"\n-    )\n-    password = FormElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",html_type=\"password\")\n-\n-\n-\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex 4055bbd..8583142 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,3 +1,3 @@\n-from snek.app import app \n+from snek.app import app\n \n application = app\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nnew file mode 100644\nindex 0000000..44553f8\n--- /dev/null\n+++ b/src/snek/model/user.py\n@@ -0,0 +1,19 @@\n+from snek.system.model import BaseModel,ModelField\n+\n+class User(BaseModel):\n+    \n+    username = ModelField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+    )\n+    email = ModelField(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n+    )\n+    password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n+\n+\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 701beda..f06ee24 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -57,13 +57,26 @@ class Room {\n         this.name = name \n     }\n     setMessages(list){\n-        \n+\n     }\n \n \n }\n \n \n+class InlineAppElement extends HTMLElement {\n+    \n+    constructor(){\n+        this.\n+    }\n+\n+}\n+\n+class Page {\n+    elements = []\n+\n+}\n+\n class App {\n     rooms = []\n     constructor() {\ndiff --git a/src/snek/static/styles.css b/src/snek/static/base.css\nsimilarity index 94%\nrename from src/snek/static/styles.css\nrename to src/snek/static/base.css\nindex 83c1fb1..363e4f1 100644\n--- a/src/snek/static/styles.css\n+++ b/src/snek/static/base.css\n@@ -1,11 +1,9 @@\n * {\n   margin: 0;\n   padding: 0;\n   box-sizing: border-box;\n }\n \n body {\n   font-family: Arial, sans-serif;\n@@ -16,7 +14,6 @@ body {\n   height: 100vh;\n }\n \n header {\n   padding: 10px 20px;\n@@ -43,14 +40,12 @@ header nav a:hover {\n }\n \n main {\n   display: flex;\n   flex: 1;\n   overflow: hidden;\n }\n \n .sidebar {\n   width: 250px;\n@@ -84,7 +79,6 @@ main {\n }\n \n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -103,7 +97,6 @@ main {\n }\n \n .chat-messages {\n   flex: 1;\n   padding: 20px;\n@@ -155,7 +148,6 @@ main {\n }\n \n .chat-input {\n   padding: 15px;\n@@ -190,7 +182,6 @@ main {\n }\n \n @media (max-width: 768px) {\n   .sidebar {\n     display: none;\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nnew file mode 100644\nindex 0000000..5407a3b\n--- /dev/null\n+++ b/src/snek/static/fancy-button.js\n@@ -0,0 +1,54 @@\n+\n+\n+class FancyButton extends HTMLElement {\n+    url = null\n+    type=\"button\"\n+    value = null\n+    constructor(){\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement('span')\n+        this.styleElement = document.createElement(\"style\")\n+        this.styleElement.innerHTML = `\n+        :root {\n+        width:100%;\n+            --width: 100%;\n+    }\n+        button {\n+            width: var(--width);\n+            min-width: 33%;\n+            padding: 10px;\n+            border: none;\n+            border-radius: 5px;\n+            color: white;\n+            font-size: 1em;\n+            font-weight: bold;\n+            cursor: pointer;\n+            transition: background-color 0.3s;\n+    }\n+        button:hover {\n+  }\n+    `\n+    this.container.appendChild(this.styleElement)\n+        this.buttonElement = document.createElement('button')\n+        this.container.appendChild(this.buttonElement)\n+        this.shadowRoot.appendChild(this.container)\n+    }\n+\n+    connectedCallback() {\n+        this.url = this.getAttribute('url');\n+        this.value = this.getAttribute('value')\n+        const me = this \n+        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n+        this.buttonElement.addEventListener(\"click\",()=>{\n+            if(me.url){\n+                window.location = me.url\n+            }\n+        })\n+    }\n+}\n+\n+customElements.define(\"fancy-button\",FancyButton)\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nnew file mode 100644\nindex 0000000..d593816\n--- /dev/null\n+++ b/src/snek/static/generic-form.css\n@@ -0,0 +1,100 @@\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+  \n+  body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    height: 100vh;\n+  }\n+\n+generic-form {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+\n+}\n+\n+.generic-form-container {\n+    \n+    border-radius: 10px;\n+    padding: 30px;\n+    width: 400px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+    text-align: center;\n+\n+}\n+\n+.generic-form-container h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+}\n+input {\n+\n+}\n+.generic-form-container generic-field {\n+    width: 100%;\n+    padding: 10px;\n+    margin: 10px 0;\n+    border-radius: 5px;\n+    font-size: 1em;\n+}\n+\n+.generic-form-container button {\n+    width: 100%;\n+    padding: 10px;\n+    border: none;\n+    border-radius: 5px;\n+    color: white;\n+    font-size: 1em;\n+    font-weight: bold;\n+    cursor: pointer;\n+    transition: background-color 0.3s;\n+}\n+\n+.generic-form-container button:hover {\n+}\n+\n+.generic-form-container a {\n+    text-decoration: none;\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+}\n+\n+.generic-form-container a:hover {\n+}\n+\n+\n+.error {\n+    font-size: 0.9em;\n+    margin-top: 5px;\n+}\n+\n+\n+@media (max-width: 500px) {\n+    .generic-form-container {\n+        width: 90%;\n+    }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nnew file mode 100644\nindex 0000000..11fea47\n--- /dev/null\n+++ b/src/snek/static/generic-form.js\n@@ -0,0 +1,321 @@\n+\n+class GenericField extends HTMLElement {\n+  form = null\n+  field = null \n+  inputElement = null\n+  footerElement = null \n+  action = null \n+  container = null\n+  styleElement = null\n+  name = null\n+    get value() {\n+      return this.inputElement.value\n+    }\n+    get type() {\n+\n+        return this.field.tag \n+    }\n+    set value(val) {\n+      val = val == null ? '' : val \n+      this.inputElement.value = val \n+      this.inputElement.setAttribute(\"value\", val)\n+    }\n+    setInvalid(){\n+      this.inputElement.classList.add(\"error\")\n+      this.inputElement.classList.remove(\"valid\")\n+    }\n+    setErrors(errors){\n+      if(errors.length)\n+        this.inputElement.setAttribute(\"title\", errors[0])\n+      else\n+       this.inputElement.setAttribute(\"title\",\"\")\n+    }\n+    setValid(){\n+      this.inputElement.classList.remove(\"error\")\n+      this.inputElement.classList.add(\"valid\")\n+    }\n+    constructor() {\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement('div')\n+        this.styleElement = document.createElement('style')\n+        this.styleElement.innerHTML = `\n+\n+            h1 {\n+              font-size: 2em;\n+              margin-bottom: 20px;\n+              margin-top: 0px;\n+            }\n+\n+            input {\n+                width: 90%;\n+                padding: 10px;\n+                margin: 10px 0;\n+                border-radius: 5px;\n+                font-size: 1em;\n+            }\n+\n+            button {\n+                width: 50%;\n+                padding: 10px;\n+                border: none;\n+               float: right;\n+                margin-top: 10px;\n+                margin-left: 10px;\n+                margin-right: 10px;\n+                border-radius: 5px;\n+                color: white;\n+                font-size: 1em;\n+                font-weight: bold;\n+                cursor: pointer;\n+                transition: background-color 0.3s;\n+                clear: both;\n+            }\n+\n+            button:hover {\n+            }\n+\n+            a {\n+                text-decoration: none;\n+                display: block;\n+                margin-top: 15px;\n+                font-size: 0.9em;\n+                transition: color 0.3s;\n+            }\n+\n+            a:hover {\n+            }\n+            .valid {\n+                border: 1px solid green;\n+                color:green;\n+                font-size: 0.9em;\n+                margin-top: 5px;\n+            }\n+            .error {\n+              border: 3px solid red;\n+                font-size: 0.9em;\n+                margin-top: 5px;\n+            }\n+            @media (max-width: 500px) {\n+                input {\n+                    width: 90%;\n+                }\n+            }    \n+            \n+            `\n+        this.container.appendChild(this.styleElement)\n+        \n+        this.shadowRoot.appendChild(this.container)\n+    }\n+    connectedCallback(){\n+\n+      this.updateAttributes() \n+       \n+    }\n+    setAttribute(name,value){\n+        this[name] = value \n+    }\n+    updateAttributes(){\n+      if(this.inputElement == null && this.field){\n+       this.inputElement = document.createElement(this.field.tag)\n+       if(this.field.tag == 'button'){\n+          if(this.field.value == \"submit\"){\n+          \n+          \n+          }\n+          this.action = this.field.value\n+       }\n+       this.inputElement.name = this.field.name \n+       this.name = this.inputElement.name\n+       const me = this\n+       this.inputElement.addEventListener(\"keyup\",(e)=>{\n+        if(e.key == 'Enter'){\n+            me.dispatchEvent(new Event(\"submit\"))\n+          }else if(me.field.value != e.target.value)\n+          {\n+            const event = new CustomEvent(\"change\", {detail:me,bubbles:true})\n+            me.dispatchEvent(event)\n+          }\n+       })\n+       this.inputElement.addEventListener(\"click\",(e)=>{\n+          const event = new CustomEvent(\"click\",{detail:me,bubbles:true})\n+         me.dispatchEvent(event)    \n+       })\n+        this.container.appendChild(this.inputElement)\n+\n+}\n+      if(!this.field){\n+        return\n+      }\n+        this.inputElement.setAttribute(\"type\",this.field.type == null ? 'input' : this.field.type)\n+        this.inputElement.setAttribute(\"name\",this.field.name  == null ? '' : this.field.name)\n+        \n+        if(this.field.text != null){\n+          this.inputElement.innerText = this.field.text \n+        }\n+        if(this.field.html != null){\n+          this.inputElement.innerHTML = this.field.html\n+        }\n+        if(this.field.class_name){\n+          this.inputElement.classList.add(this.field.class_name)\n+        }\n+        this.inputElement.setAttribute(\"tabindex\", this.field.index)\n+        this.inputElement.classList.add(this.field.name)\n+        this.value = this.field.value\n+        let place_holder = null \n+        if(this.field.place_holder)\n+            place_holder = this.field.place_holder\n+        if(this.field.required && place_holder){\n+          place_holder = place_holder\n+        }\n+        if(place_holder)\n+          this.field.place_holder = \"* \" + place_holder\n+        this.inputElement.setAttribute(\"placeholder\",place_holder)\n+        if(this.field.required)\n+            this.inputElement.setAttribute(\"required\",\"required\")\n+        else\n+            this.inputElement.removeAttribute(\"required\")\n+        if(!this.footerElement){\n+            this.footerElement = document.createElement('div')\n+            this.footerElement.style.clear = 'both'\n+            this.container.appendChild(this.footerElement)\n+        }\n+    }\n+}\n+\n+customElements.define('generic-field', GenericField);\n+\n+class GenericForm extends HTMLElement {\n+  fields = {}  \n+  form = {}\n+  constructor() {\n+\n+\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.styleElement = document.createElement(\"style\")\n+      this.styleElement.innerHTML = `\n+\n+      * {\n+          margin: 0;\n+          padding: 0;\n+          box-sizing: border-box;\n+          width:90%\n+\n+      }\n+\n+      div {\n+          \n+          border-radius: 10px;\n+          padding: 30px;\n+          width: 400px;\n+          box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+          text-align: center;\n+\n+      }\n+      @media (max-width: 500px) {\n+          form {\n+              width: 80%;\n+          }\n+      }`\n+      \n+      this.container = document.createElement('div');\n+      this.container.appendChild(this.styleElement)\n+      this.container.classList.add(\"generic-form-container\")\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadForm(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No URL provided!\";\n+      }\n+    }\n+\n+    async loadForm(url) {\n+      const me = this \n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+        }\n+        me.form = await response.json();\n+        \n+        let fields = Object.values(me.form.fields)\n+        \n+        fields = fields.sort((a,b)=>{\n+          console.info(a.index,b.index)\n+          return a.index - b.index\n+        })  \n+        fields.forEach(field=>{\n+          const fieldElement = document.createElement('generic-field')\n+          me.fields[field.name] = fieldElement \n+          fieldElement.setAttribute(\"form\", me)\n+          fieldElement.setAttribute(\"field\", field)\n+          me.container.appendChild(fieldElement)\n+          fieldElement.updateAttributes() \n+          fieldElement.addEventListener(\"change\",(e)=>{\n+            me.form.fields[e.detail.name].value = e.detail.value\n+          })\n+          fieldElement.addEventListener(\"click\",async (e)=>{\n+            if(e.detail.type == \"button\"){\n+              if(e.detail.value == \"submit\")\n+              {\n+                await me.validate()\n+              }\n+            }\n+              \n+          })\n+        })\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+    async validate(){\n+      const url = this.getAttribute(\"url\")\n+      const me = this\n+      const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n+      });\n+      const form = await response.json()\n+      Object.values(form.fields).forEach(field=>{\n+        if(!me.form.fields[field.name])\n+          return\n+          me.form.fields[field.name].is_valid = field.is_valid \n+        if(!field.is_valid){\n+          me.fields[field.name].setInvalid()\n+          me.fields[field.name].setErrors(field.errors)\n+          console.info(field.name,\"is invalid\")\n+        }else{\n+          me.fields[field.name].setValid()\n+        }\n+        me.fields[field.name].setAttribute(\"field\",field)\n+        me.fields[field.name].updateAttributes()\n+      })\n+      Object.values(form.fields).forEach(field=>{\n+        console.info(field.errors)\n+        me.fields[field.name].setErrors(field.errors)\n+      })\n+    }\n+  }\n+  customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.css b/src/snek/static/html-frame.css\nsimilarity index 53%\nrename from src/snek/static/html_frame.css\nrename to src/snek/static/html-frame.css\nindex 6b64c76..92a1f97 100644\n--- a/src/snek/static/html_frame.css\n+++ b/src/snek/static/html-frame.css\n@@ -1,9 +1,6 @@\n .html-frame {\n     width: 100px;\n     height: 50px;\n-    position: relative;\n     overflow: hidden;\n     border: 1px solid black;\n-\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.js b/src/snek/static/html-frame.js\nsimilarity index 62%\nrename from src/snek/static/html_frame.js\nrename to src/snek/static/html-frame.js\nindex 19a0c34..22581ce 100644\n--- a/src/snek/static/html_frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -12,28 +12,25 @@ class HTMLFrame extends HTMLElement {\n       if (url) {\n         const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n         if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)\n-        console.info(fullUrl)    \n-        this.fetchAndDisplayHtml(fullUrl.toString());\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadAndRender(fullUrl.toString());\n       } else {\n-        this.container.textContent = \"No URL provided!\";\n+        this.container.textContent = \"No source URL!\";\n       }\n     }\n \n-    async fetchAndDisplayHtml(url) {\n+    async loadAndRender(url) {\n       try {\n         const response = await fetch(url);\n         if (!response.ok) {\n-          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+          throw new Error(`Error: ${response.status} ${response.statusText}`);\n         }\n         const html = await response.text();\n+        this.container.innerHTML = html;\n         \n     } catch (error) {\n         this.container.textContent = `Error: ${error.message}`;\n       }\n     }\n   }\n-\n   customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/register.css b/src/snek/static/register__.css\nsimilarity index 74%\nrename from src/snek/static/register.css\nrename to src/snek/static/register__.css\nindex fc4ca0f..57186b8 100644\n--- a/src/snek/static/register.css\n+++ b/src/snek/static/register__.css\n@@ -1,24 +1,12 @@\n+\n * {\n     margin: 0;\n     padding: 0;\n     box-sizing: border-box;\n   }\n   \n-  body {\n-    font-family: Arial, sans-serif;\n-    line-height: 1.5;\n-    display: flex;\n-    flex-direction: column;\n-    justify-content: center;\n-    align-items: center;\n-    height: 100vh;\n-  }\n   \n+  \n   .registration-container {\n     border-radius: 10px;\n@@ -26,16 +14,15 @@\n     width: 400px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n     text-align: center;\n+    left: calc(50%-200);\n   }\n   \n   .registration-container h1 {\n     font-size: 2em;\n     margin-bottom: 20px;\n   }\n   \n   .registration-container input {\n     width: 100%;\n     padding: 10px;\n@@ -47,7 +34,6 @@\n     font-size: 1em;\n   }\n   \n   .registration-container button {\n     width: 100%;\n     padding: 10px;\n@@ -65,7 +51,6 @@\n   }\n   \n   .registration-container a {\n     text-decoration: none;\n@@ -79,14 +64,11 @@\n   }\n   \n   .error {\n     font-size: 0.9em;\n     margin-top: 5px;\n   }\n-  \n   @media (max-width: 500px) {\n     .registration-container {\n       width: 90%;\ndiff --git a/src/snek/system/__init__.py b/src/snek/system/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nnew file mode 100644\nindex 0000000..68f7c0f\n--- /dev/null\n+++ b/src/snek/system/form.py\n@@ -0,0 +1,171 @@\n+from snek.system import model \n+\n+class HTMLElement(model.ModelField):\n+    def __init__(self,id:str=None, tag:str=\"div\", name:str=None,html:str=None, class_name:str=None, text:str=None, *args, **kwargs):\n+        \"\"\"\n+        Create a new HTMLElement.\n+        \n+        :param id: The id of the element\n+        :param tag: The tag of the element\n+        :param name: The name of the element, used to generate a class name if not provided\n+        :param html: The inner html of the element\n+        :param class_name: The class name of the element\n+        :param text: The text of the element\n+        \"\"\"\n+        self.tag = tag\n+        self.text = text\n+        self.id = id \n+        self.class_name = class_name or name\n+        self.html = html \n+        super().__init__(name=name,*args, **kwargs)\n+\n+    def to_json(self):\n+        \"\"\"\n+        Return a json representation of the element.\n+        \n+        This will return a dict with the following keys:\n+        \n+        - text: The text of the element\n+        - id: The id of the element\n+        - html: The inner html of the element\n+        - class_name: The class name of the element\n+        - tag: The tag of the element\n+        \n+        :return: A json representation of the element\n+        :rtype: dict\n+        \"\"\"\n+        result = super().to_json()\n+        result['text'] = self.text \n+        result['id'] = self.id \n+        result['html'] = self.html \n+        result['class_name'] = self.class_name\n+        result['tag'] = self.tag\n+        return result \n+\n+class FormElement(HTMLElement):\n+    pass\n+           \n+class FormInputElement(FormElement):\n+\n+    def __init__(self,type=\"text\",place_holder=None, *args, **kwargs):\n+        \"\"\"\n+        Initialize a FormInputElement with specified attributes.\n+\n+        :param type: The type of the input element (default is \"text\").\n+        :param place_holder: The placeholder text for the input element.\n+        :param args: Additional positional arguments.\n+        :param kwargs: Additional keyword arguments.\n+        \"\"\"\n+\n+        super().__init__(tag=\"input\", *args, **kwargs)\n+        self.place_holder = place_holder \n+        self.type = type\n+        \n+\n+    def to_json(self):\n+        \"\"\"\n+        Return a json representation of the element.\n+\n+        This will return a dict with the following keys:\n+\n+        - place_holder: The placeholder text for the input element\n+        - type: The type of the input element\n+\n+        :return: A json representation of the element\n+        :rtype: dict\n+        \"\"\"\n+        data = super().to_json()\n+        data[\"place_holder\"] = self.place_holder\n+        data[\"type\"] = self.type\n+        return data \n+    \n+class FormButtonElement(FormElement):\n+    def __init__(self, tag=\"button\", *args, **kwargs):\n+        \"\"\"\n+        Initialize a FormButtonElement with specified attributes.\n+\n+        :param tag: The tag of the button element (default is \"button\").\n+        :param args: Additional positional arguments.\n+        :param kwargs: Additional keyword arguments.\n+        \"\"\"\n+        super().__init__(tag=tag, *args, **kwargs)\n+\n+\n+class Form(model.BaseModel):\n+    \n+    @property\n+    def html_elements(self):\n+        \"\"\"\n+        Return a list of all :class:`HTMLElement` objects in the form.\n+\n+        This is a convenience property that filters the :attr:`fields` list to only\n+        include elements that are instances of :class:`HTMLElement`.\n+\n+        :return: A list of :class:`HTMLElement` objects\n+        :rtype: list\n+        \"\"\"\n+        json_elements = super().to_json()\n+        return [element for element in self.fields if isinstance(element,HTMLElement)]\n+    def set_user_data(self, data):\n+        \"\"\"\n+        Set user data for the form by updating the fields with the provided data.\n+\n+        This method extracts the 'fields' key from the provided data dictionary\n+        and passes it to the parent class's `set_user_data` method to update the\n+        form fields accordingly.\n+\n+        :param data: A dictionary containing the form data, expected to have a \n+                    'fields' key with the data to update the form fields.\n+        \"\"\"\n+\n+        return super().set_user_data(data.get('fields'))\n+\n+    def to_json(self, encode=False):\n+        \"\"\"\n+        Return a JSON representation of the form, including field values and metadata.\n+\n+        This method returns a dictionary with the following keys:\n+\n+        - ``fields``: A dictionary of field names to their current values.\n+        - ``is_valid``: A boolean indicating whether the form is valid.\n+        - ``errors``: A dictionary of field names to lists of error strings.\n+\n+        If the ``encode`` argument is ``True``, the dictionary will be JSON-encoded\n+        before being returned. Otherwise, the dictionary is returned directly.\n+\n+        :param encode: If ``True``, JSON-encode the returned dictionary.\n+        :type encode: bool\n+        :return: A JSON representation of the form.\n+        :rtype: dict\n+        \"\"\"\n+        elements = super().to_json()\n+        html_elements = {}\n+        for element in elements.keys():\n+            print(\"DDD!\",element,flush=True)\n+            field = getattr(self,element)\n+            if isinstance(field,HTMLElement):\n+                print(\"QQQQ!\",element,flush=True)\n+                try:\n+                    html_elements[element] = elements[element]\n+                except KeyError:\n+                    pass \n+\n+        return dict(fields=html_elements,is_valid=self.is_valid,errors=self.errors)\n+    @property\n+    def errors(self):\n+        \"\"\"\n+        Return a list of all error strings from all fields in the form.\n+\n+        The list will be empty if all fields are valid.\n+\n+        :return: A list of error strings.\n+        :rtype: list\n+        \"\"\"\n+        result = []\n+        for field in self.html_elements:\n+            result += field.errors \n+        return result \n+    @property\n+    def is_valid(self):\n+        return all(element.is_valid for element in self.html_elements)\ndiff --git a/src/snek/http.py b/src/snek/system/http.py\nsimilarity index 100%\nrename from src/snek/http.py\nrename to src/snek/system/http.py\ndiff --git a/src/snek/middleware.py b/src/snek/system/middleware.py\nsimilarity index 100%\nrename from src/snek/middleware.py\nrename to src/snek/system/middleware.py\ndiff --git a/src/snek/models.py b/src/snek/system/model.py\nsimilarity index 78%\nrename from src/snek/models.py\nrename to src/snek/system/model.py\nindex ec5d9ce..a699a9e 100644\n--- a/src/snek/models.py\n+++ b/src/snek/system/model.py\n@@ -23,7 +23,7 @@ def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**k\n             return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n \n class Validator:\n-\n+    _index = 0\n     @property\n     def value(self):\n         return self._value \n@@ -34,12 +34,14 @@ class Validator:\n \n     @property\n     def initial_value(self):\n-        return None\n+        return self.value\n \n     def custom_validation(self):\n         return True\n \n     def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+        self.index = Validator._index\n+        Validator._index += 1\n         self.required = required \n         self.min_num = min_num \n         self.max_num = max_num\n@@ -47,8 +49,10 @@ class Validator:\n         self.max_length = max_length \n         self.regex = regex \n         self._value = None \n-        self.value = value \n-        self.type = kind\n+        self.value = value\n+        print(\"xxxx\", value,flush=True) \n+        \n+        self.kind = kind\n         self.help_text = help_text \n         self.__dict__.update(kwargs)\n     @property \n@@ -61,7 +65,7 @@ class Validator:\n         if self.value is None:\n             return error_list \n \n-        if self.type == float or self.type == int:\n+        if self.kind == float or self.kind == int:\n             if self.min_num is not None and self.value < self.min_num:\n                 error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n             if self.max_num is not None and self.value > self.max_num:\n@@ -70,10 +74,11 @@ class Validator:\n             error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n         if self.max_length is not None and len(self.value) > self.max_length:\n             error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n+        print(self.regex, self.value,flush=True)\n         if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\".format(self.regex))\n-        if not self.type is None and type(self.value) != self.type:\n-            error_list.append(\"Invalid type. It is supposed to be {}.\".format(self.type))\n+        if not self.kind is None and type(self.value) != self.kind:\n+            error_list.append(\"Invalid kind. It is supposed to be {}.\".format(self.kind))\n         return error_list \n         \n     def validate(self):\n@@ -89,6 +94,8 @@ class Validator:\n         except ValueError:\n             return False\n \n+    \n+\n     def to_json(self):\n         return {\n             \"required\": self.required,\n@@ -98,19 +105,26 @@ class Validator:\n             \"max_length\": self.max_length,\n             \"regex\": self.regex,\n             \"value\": self.value,\n-            \"type\": self.type,\n+            \"kind\": str(self.kind),\n             \"help_text\": self.help_text,\n             \"errors\": self.errors,\n-            \"is_valid\": self.is_valid\n+            \"is_valid\": self.is_valid,\n+            \"index\":self.index\n         }\n \n class ModelField(Validator):\n+\n+    index = 1\n     def __init__(self,name=None,save=True, *args, **kwargs):\n         self.name = name \n-        \n         self.save = save\n         super().__init__(*args, **kwargs)\n \n+    def to_json(self):\n+        result = super().to_json()\n+        result['name'] = self.name\n+        return result \n+\n \n class CreatedField(ModelField):\n     \n@@ -146,9 +160,11 @@ class BaseModel:\n     updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n     deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n \n+   \n     def __init__(self, *args, **kwargs):\n         print(self.__dict__)\n         print(dir(self.__class__))\n+        self.fields = {}\n         for key in dir(self.__class__):\n             obj = getattr(self.__class__,key)\n \n@@ -156,6 +172,7 @@ class BaseModel:\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 print(\"JAAA\")\n                 self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+                self.fields[key] = self.__dict__[key]\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -169,6 +186,22 @@ class BaseModel:\n             return obj.value \n         return obj\n \n+    def set_user_data(self, data):\n+        for key, value in data.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue \n+            if value.get('name'):\n+                value = value.get('value')\n+            field.value = value\n+           \n+\n+    @property \n+    def is_valid(self):\n+        for field in self.fields.values():\n+            if not field.is_valid:\n+                return False\n+        return True\n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -180,7 +213,7 @@ class BaseModel:\n         if isinstance(obj,Validator):\n             obj.value = value\n         else:\n-            setattr(self,key,value)\n@@ -201,6 +234,7 @@ class BaseModel:\n             \"updated_at\": self.updated_at.value,\n             \"deleted_at\": self.deleted_at.value\n         })\n+        \n         for key,value in self.__dict__.items(): \n             if key == \"record\":\n                 continue\n@@ -225,39 +259,8 @@ class FormElement(ModelField):\n         self.place_holder = place_holder \n         super().__init__(*args, **kwargs)\n \n-\n     def to_json(self):\n         data = super().to_json()\n         data[\"name\"] = self.name \n         data[\"place_holder\"] = self.place_holder\n         return data \n-\n-\n-\n-\n-class TestModel(BaseModel):\n-\n-    first_name = FormElement(name=\"first_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"First name\")\n-    last_name = FormElement(name=\"last_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Last name\")\n-    email = FormElement(name=\"email\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")  \n-\n-class Form:\n-    username = FormElement(required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Username\")\n-    email = FormElement(required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")\n-    def __init__(self, *args, **kwargs):\n-        self.place_holder = kwargs.pop(\"place_holder\",None) \n-\n-\n-if __name__ == \"__main__\":\n-    model = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"n9K9p@example.com\",password=\"Password123\")\n-    model2 = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"ddd\",password=\"zzz\")\n-    model.first_name = \"AAA\"\n-    print(model.first_name)\n-    print(model.first_name.value)\n-    \n-    print(model.first_name)\n-    print(model.first_name.value)\n-    print(model.to_json(True))\n-    print(model2.to_json(True))\n-    print(model2.record)\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nnew file mode 100644\nindex 0000000..5b1bdf2\n--- /dev/null\n+++ b/src/snek/templates/base.html\n@@ -0,0 +1,31 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>{% block title %}{% endblock %}</title>\n+   <script src=\"/fancy-button.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n+    <script src=\"/html-frame.js\"></script>\n+    <script src=\"/generic-form.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n+  \n+</head>\n+<body>\n+    <header>\n+        {% block header %}\n+        {% endblock %}\n+\n+    </header>\n+    <main>\n+        <aside class=\"sidebar\">\n+        {% block sidebar %}\n+        \n+        {% endblock %}\n+    </aside>\n+    {% block main %}\n+    {% endblock %}\n+</main>\n+</body>\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/templates/base_chat.html b/src/snek/templates/base_chat.html\nnew file mode 100644\nindex 0000000..09c025b\n--- /dev/null\n+++ b/src/snek/templates/base_chat.html\n@@ -0,0 +1,31 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>{% block title %}{% endblock %}</title>\n+   <script src=\"/fancy-button.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/base.css\">\n+    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n+    <script src=\"/html-frame.js\"></script>\n+    <script src=\"/generic-form.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n+    <link rel=\"stylesheet\" href=\"/register__.css\">\n+</head>\n+<body>\n+    <header>\n+        {% block header %}\n+        {% endblock %}\n+\n+    </header>\n+    <main>\n+        <aside class=\"sidebar\">\n+            {% block sidebar %}\n+            \n+            {% endblock %}\n+        </aside>\n+    {% block main %}\n+    {% endblock %}\n+</main>\n+</body>\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nnew file mode 100644\nindex 0000000..1ee1f77\n--- /dev/null\n+++ b/src/snek/templates/index.html\n@@ -0,0 +1,20 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Snek chat by Molodetz</title>\n+    <link rel=\"stylesheet\" href=\"generic-form.css\">\n+  <link rel=\"stylesheet\" href=\"register__.css\">\n+    <script src=\"/fancy-button.js\"></script>\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Snek</h1>\n+    <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n+    <span style=\"padding:10px;\">Or</span>\n+    <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n+\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex d37f3fa..c09ec70 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,20 +1,5 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Register</title>\n-  <link rel=\"stylesheet\" href=\"register.css\">\n-</head>\n-<body>\n-  <div class=\"registration-container\">\n-    <h1>Login</h1>\n-    <form>\n-      <input type=\"text\" name=\"username\" placeholder=\"Username or password\" required>\n-      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n-      <button type=\"submit\">Create Account</button>\n-      <a href=\"/register\">Not having an account yet? Register here.</a>\n-    </form>\n-  </div>\n-</body>\n-</html>\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+  <generic-form url=\"/login-form\"></generic-form>\n+{% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex da41629..61da961 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,22 +1,5 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Register</title>\n-  <link rel=\"stylesheet\" href=\"register.css\">\n-</head>\n-<body>\n-  <div class=\"registration-container\">\n-    <h1>Register</h1>\n-    <form>\n-      <input type=\"text\" name=\"username\" placeholder=\"Username\" required>\n-      <input type=\"email\" name=\"email\" placeholder=\"Email Address\" required>\n-      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n-      <input type=\"password\" name=\"confirm_password\" placeholder=\"Confirm Password\" required>\n-      <button type=\"submit\">Create Account</button>\n-    </form>\n-  </div>\n-</body>\n-</html>\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+  <generic-form url=\"/register-form\"></generic-form>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/test.html b/src/snek/templates/web.html\nsimilarity index 92%\nrename from src/snek/templates/test.html\nrename to src/snek/templates/web.html\nindex c23103a..0403e1b 100644\n--- a/src/snek/templates/test.html\n+++ b/src/snek/templates/web.html\n@@ -4,9 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Dark Themed Chat Application</title>\n-  <link rel=\"stylesheet\" href=\"styles.css\">\n-  <script src=\"/html_frame.js\"></script>\n-  <script src=\"/html_frame.css\"></script>\n+  <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n   <header>\n@@ -59,5 +57,4 @@\n   </main>\n   \n </body>\n-</html>\n-\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/view/base.py b/src/snek/view/base.py\nnew file mode 100644\nindex 0000000..d962ee5\n--- /dev/null\n+++ b/src/snek/view/base.py\n@@ -0,0 +1,31 @@\n+from aiohttp import web \n+\n+class BaseView(web.View):\n+    \n+    @property \n+    def app(self):\n+        return self.request.app\n+    \n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    def json_response(self, data):\n+        return web.json_response(data)\n+\n+    def render_template(self, template_name, context=None):\n+        return self.request.app.render_template(template_name, self.request,context)\n+    \n+class BaseFormView(BaseView):\n+\n+    form = None \n+\n+    async def get(self):\n+        form = self.form()\n+        return self.json_response(form.to_json())\n+       \n+    async def post(self):\n+        form = self.form()\n+        post = await self.request.json()\n+        form.set_user_data(post['form'])\n+        return self.json_response(form.to_json())  \n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nnew file mode 100644\nindex 0000000..a5d8b92\n--- /dev/null\n+++ b/src/snek/view/index.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView\n+\n+class IndexView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nnew file mode 100644\nindex 0000000..3a3beaf\n--- /dev/null\n+++ b/src/snek/view/login.py\n@@ -0,0 +1,13 @@\n+from snek.form.register import RegisterForm\n+from snek.view.base import BaseView \n+\n+class LoginView(BaseView):\n+\n+    async def get(self):\n+        \n+    async def post(self):\n+        form = RegisterForm()\n+        form.set_user_data(await self.request.post())\n+        print(form.is_valid())\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nnew file mode 100644\nindex 0000000..26527da\n--- /dev/null\n+++ b/src/snek/view/login_form.py\n@@ -0,0 +1,5 @@\n+from snek.view.base import BaseFormView\n+from snek.form.login import LoginForm\n+\n+class LoginFormView(BaseFormView):\n+    form = LoginForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nnew file mode 100644\nindex 0000000..095b7a3\n--- /dev/null\n+++ b/src/snek/view/register.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView \n+\n+class RegisterView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"register.html\")  \n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nnew file mode 100644\nindex 0000000..0ae7630\n--- /dev/null\n+++ b/src/snek/view/register_form.py\n@@ -0,0 +1,5 @@\n+from snek.form.register import RegisterForm\n+from snek.view.base import BaseFormView \n+\n+class RegisterFormView(BaseFormView):\n+    form = RegisterForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/view.py b/src/snek/view/view.py\nnew file mode 100644\nindex 0000000..ea642a3\n--- /dev/null\n+++ b/src/snek/view/view.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView \n+\n+class WebView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"web.html\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor system and views for improved structure and maintainability", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527", "diff": "commit d20079f3ed8f261bcda0f5379f4c9e23ee941527\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:00:10 2025 +0100\n\n    Complete system.\n\ndiff --git a/.gitignore b/.gitignore\nindex 3747073..ece77be 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,5 +1,8 @@\n .vscode\n .history\n+.resources\n+.backup*\n+docs\n *.db*\n *.png\ndiff --git a/Dockerfile b/Dockerfile\nindex 47c0ece..9af8e87 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -1,5 +1,5 @@\n FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n-FROM python:3.10-alpine\n+FROM python:3.12.8-alpine3.21\n WORKDIR /code\n ENV FLASK_APP=app.py\n ENV FLASK_RUN_HOST=0.0.0.0\n@@ -37,5 +37,5 @@ RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 8081\n \n-python -m snek.app\n+CMD [\"python\",\"-m\",\"snek.app\"]\ndiff --git a/pyproject.toml b/pyproject.toml\nindex d98557e..cc36846 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -20,6 +20,8 @@ dependencies = [\n     \"beautifulsoup4\",\n     \"gunicorn\",\n     \"imgkit\",\n-    \"wkhtmltopdf\"\n+    \"wkhtmltopdf\",\n+    \"jinja-markdown2\",\n+    \"mistune\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0e2ed63..deac5d3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,14 +3,16 @@ import pathlib\n from aiohttp import web\n from app.app import Application as BaseApplication\n \n+from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n+from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n-from snek.view.view import WebView\n+from snek.view.web import WebView\n \n \n class Application(BaseApplication):\n@@ -24,6 +26,7 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n+        self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n \n     def setup_router(self):\n@@ -34,12 +37,14 @@ class Application(BaseApplication):\n             name=\"static\",\n             show_index=True,\n         )\n-        self.router.add_view(\"/web\", WebView)\n-        self.router.add_view(\"/login\", LoginView)\n-        self.router.add_view(\"/login-form\", LoginFormView)\n-        self.router.add_view(\"/register\", RegisterView)\n+        self.router.add_view(\"/about.html\", AboutHTMLView)\n+        self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/web.html\", WebView)\n+        self.router.add_view(\"/login.html\", LoginView)\n+        self.router.add_view(\"/login-form.json\", LoginFormView)\n+        self.router.add_view(\"/register.html\", RegisterView)\n         \n-        self.router.add_view(\"/register-form\", RegisterFormView)\n+        self.router.add_view(\"/register-form.json\", RegisterFormView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 60399fb..7dff3e4 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -15,7 +15,7 @@ class RegisterForm(Form):\n     )\n     email = FormInputElement(\n         name=\"email\",\n-        required=True,\n+        required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n         place_holder=\"Email address\",\n         type=\"email\"\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nnew file mode 100644\nindex 0000000..dc9e047\n--- /dev/null\n+++ b/src/snek/mapper/__init__.py\n@@ -0,0 +1,12 @@\n+import functools \n+from snek.mapper.user import UserMapper\n+\n+@functools.cache \n+def get_mappers(app=None):\n+    return dict(\n+        user=UserMapper(app=app)\n+\n+    )\n+\n+def get_mapper(name, app=None):\n+    return get_mappers(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nnew file mode 100644\nindex 0000000..5b8671e\n--- /dev/null\n+++ b/src/snek/mapper/user.py\n@@ -0,0 +1,6 @@\n+from snek.system.mapper import BaseMapper\n+from snek.model.user import UserModel\n+\n+class UserMapper(BaseMapper):\n+    table_name = \"user\"\n+    model: UserModel \n\\ No newline at end of file\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex e69de29..52af21a 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -0,0 +1,12 @@\n+from snek.model.user import UserModel \n+import functools \n+\n+@functools.cache\n+def get_models():\n+    return dict(\n+        user=UserModel\n+\n+    )\n+\n+def get_model(name):\n+    return get_models()[name]\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 44553f8..254b6c9 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,6 +1,6 @@\n from snek.system.model import BaseModel,ModelField\n \n-class User(BaseModel):\n+class UserModel(BaseModel):\n     \n     username = ModelField(\n         name=\"username\", \n@@ -11,7 +11,7 @@ class User(BaseModel):\n     )\n     email = ModelField(\n         name=\"email\",\n-        required=True,\n+        required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n     )\n     password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nnew file mode 100644\nindex 0000000..4038f70\n--- /dev/null\n+++ b/src/snek/service/__init__.py\n@@ -0,0 +1,12 @@\n+from snek.service.user import UserService \n+import functools \n+\n+@functools.cache\n+def get_services(app):\n+\n+    return dict(\n+        user = UserService(app=app)\n+\n+    )\n+def get_service(name, app=None):\n+    return get_services(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nnew file mode 100644\nindex 0000000..cde4b8c\n--- /dev/null\n+++ b/src/snek/service/user.py\n@@ -0,0 +1,16 @@\n+from snek.system.service import BaseService \n+from snek.system import security \n+\n+class UserService:\n+    mapper_name = \"user\"\n+\n+    async def create_user(self, username, password):\n+        if await self.exists(username=username):\n+            raise Exception(\"User already exists.\")\n+        model = await self.new()\n+        model.username = username\n+        model.password = await security.hash(password)\n+        if await self.save(model):\n+            return model \n+        raise Exception(f\"Failed to create user: {model.errors}.\")\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 11fea47..58a67e2 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -224,7 +224,11 @@ class GenericForm extends HTMLElement {\n \n       }\n       @media (max-width: 500px) {\n+        width:100%;\n+        height:100%;\n           form {\n+              height:100%;\n+              width: 100%;\n               width: 80%;\n           }\n       }`\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 22581ce..0d5d4c9 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -26,7 +26,16 @@ class HTMLFrame extends HTMLElement {\n           throw new Error(`Error: ${response.status} ${response.statusText}`);\n         }\n         const html = await response.text();\n-        this.container.innerHTML = html;\n+        if(url.endsWith(\".md\")){\n+          const parent = this\n+          const markdownElement = document.createElement('div')\n+          markdownElement.innerHTML = html\n+          document.body.appendChild(markdownElement)\n+          \n+        }else{\n+          this.container.innerHTML = html;\n+        }\n         \n     } catch (error) {\n         this.container.textContent = `Error: ${error.message}`;\ndiff --git a/src/snek/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nnew file mode 100644\nindex 0000000..e2b7a77\n--- /dev/null\n+++ b/src/snek/static/markdown-frame.js\n@@ -0,0 +1,39 @@\n+\n+\n+\n+class HTMLFrame extends HTMLElement {\n+    constructor() {\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.container = document.createElement('div');\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+        this.container.classList.add(\"html_frame\")\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadAndRender(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No source URL!\";\n+      }\n+    }\n+\n+    async loadAndRender(url) {\n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Error: ${response.status} ${response.statusText}`);\n+        }\n+        const html = await response.text();\n+        this.container.innerHTML = html;\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+  }\n+  customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nnew file mode 100644\nindex 0000000..990fcf9\n--- /dev/null\n+++ b/src/snek/static/style.css\n@@ -0,0 +1,20 @@\n+h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+}\n+\n+h2 {\n+    font-size: 1.4em;\n+    margin-bottom: 20px;\n+}\n+body {\n+\n+}\n+div {\n+    text-align: left;\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/system/api.py b/src/snek/system/api.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 68f7c0f..f9ebebb 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,171 +1,96 @@\n-from snek.system import model \n+\n+\n+\n+\n+from snek.system import model\n \n class HTMLElement(model.ModelField):\n-    def __init__(self,id:str=None, tag:str=\"div\", name:str=None,html:str=None, class_name:str=None, text:str=None, *args, **kwargs):\n-        \"\"\"\n-        Create a new HTMLElement.\n-        \n-        :param id: The id of the element\n-        :param tag: The tag of the element\n-        :param name: The name of the element, used to generate a class name if not provided\n-        :param html: The inner html of the element\n-        :param class_name: The class name of the element\n-        :param text: The text of the element\n-        \"\"\"\n+    def __init__(self, id=None, tag=\"div\", name=None, html=None, class_name=None, text=None, *args, **kwargs):\n         self.tag = tag\n         self.text = text\n-        self.id = id \n+        self.id = id\n         self.class_name = class_name or name\n-        self.html = html \n-        super().__init__(name=name,*args, **kwargs)\n+        self.html = html\n+        super().__init__(name=name, *args, **kwargs)\n \n     def to_json(self):\n-        \"\"\"\n-        Return a json representation of the element.\n-        \n-        This will return a dict with the following keys:\n-        \n-        - text: The text of the element\n-        - id: The id of the element\n-        - html: The inner html of the element\n-        - class_name: The class name of the element\n-        - tag: The tag of the element\n-        \n-        :return: A json representation of the element\n-        :rtype: dict\n-        \"\"\"\n         result = super().to_json()\n-        result['text'] = self.text \n-        result['id'] = self.id \n-        result['html'] = self.html \n+        result['text'] = self.text\n+        result['id'] = self.id\n+        result['html'] = self.html\n         result['class_name'] = self.class_name\n         result['tag'] = self.tag\n-        return result \n+        return result\n \n class FormElement(HTMLElement):\n     pass\n-           \n-class FormInputElement(FormElement):\n-\n-    def __init__(self,type=\"text\",place_holder=None, *args, **kwargs):\n-        \"\"\"\n-        Initialize a FormInputElement with specified attributes.\n-\n-        :param type: The type of the input element (default is \"text\").\n-        :param place_holder: The placeholder text for the input element.\n-        :param args: Additional positional arguments.\n-        :param kwargs: Additional keyword arguments.\n-        \"\"\"\n \n+class FormInputElement(FormElement):\n+    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n         super().__init__(tag=\"input\", *args, **kwargs)\n-        self.place_holder = place_holder \n+        self.place_holder = place_holder\n         self.type = type\n-        \n \n     def to_json(self):\n-        \"\"\"\n-        Return a json representation of the element.\n-\n-        This will return a dict with the following keys:\n-\n-        - place_holder: The placeholder text for the input element\n-        - type: The type of the input element\n-\n-        :return: A json representation of the element\n-        :rtype: dict\n-        \"\"\"\n         data = super().to_json()\n         data[\"place_holder\"] = self.place_holder\n         data[\"type\"] = self.type\n-        return data \n-    \n+        return data\n+\n class FormButtonElement(FormElement):\n     def __init__(self, tag=\"button\", *args, **kwargs):\n-        \"\"\"\n-        Initialize a FormButtonElement with specified attributes.\n-\n-        :param tag: The tag of the button element (default is \"button\").\n-        :param args: Additional positional arguments.\n-        :param kwargs: Additional keyword arguments.\n-        \"\"\"\n         super().__init__(tag=tag, *args, **kwargs)\n \n-\n class Form(model.BaseModel):\n-    \n     @property\n     def html_elements(self):\n-        \"\"\"\n-        Return a list of all :class:`HTMLElement` objects in the form.\n-\n-        This is a convenience property that filters the :attr:`fields` list to only\n-        include elements that are instances of :class:`HTMLElement`.\n-\n-        :return: A list of :class:`HTMLElement` objects\n-        :rtype: list\n-        \"\"\"\n         json_elements = super().to_json()\n-        return [element for element in self.fields if isinstance(element,HTMLElement)]\n-    def set_user_data(self, data):\n-        \"\"\"\n-        Set user data for the form by updating the fields with the provided data.\n-\n-        This method extracts the 'fields' key from the provided data dictionary\n-        and passes it to the parent class's `set_user_data` method to update the\n-        form fields accordingly.\n-\n-        :param data: A dictionary containing the form data, expected to have a \n-                    'fields' key with the data to update the form fields.\n-        \"\"\"\n+        return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n+    def set_user_data(self, data):\n         return super().set_user_data(data.get('fields'))\n \n     def to_json(self, encode=False):\n-        \"\"\"\n-        Return a JSON representation of the form, including field values and metadata.\n-\n-        This method returns a dictionary with the following keys:\n-\n-        - ``fields``: A dictionary of field names to their current values.\n-        - ``is_valid``: A boolean indicating whether the form is valid.\n-        - ``errors``: A dictionary of field names to lists of error strings.\n-\n-        If the ``encode`` argument is ``True``, the dictionary will be JSON-encoded\n-        before being returned. Otherwise, the dictionary is returned directly.\n-\n-        :param encode: If ``True``, JSON-encode the returned dictionary.\n-        :type encode: bool\n-        :return: A JSON representation of the form.\n-        :rtype: dict\n-        \"\"\"\n         elements = super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n-            print(\"DDD!\",element,flush=True)\n-            field = getattr(self,element)\n-            if isinstance(field,HTMLElement):\n-                print(\"QQQQ!\",element,flush=True)\n+            field = getattr(self, element)\n+            if isinstance(field, HTMLElement):\n                 try:\n                     html_elements[element] = elements[element]\n                 except KeyError:\n-                    pass \n+                    pass\n+        return dict(fields=html_elements, is_valid=self.is_valid, errors=self.errors)\n \n-        return dict(fields=html_elements,is_valid=self.is_valid,errors=self.errors)\n     @property\n     def errors(self):\n-        \"\"\"\n-        Return a list of all error strings from all fields in the form.\n-\n-        The list will be empty if all fields are valid.\n-\n-        :return: A list of error strings.\n-        :rtype: list\n-        \"\"\"\n         result = []\n         for field in self.html_elements:\n-            result += field.errors \n-        return result \n+            result += field.errors\n+        return result\n+\n     @property\n     def is_valid(self):\n-        return all(element.is_valid for element in self.html_elements)\n+        return all(element.is_valid for element in self.html_elements)\n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex 0b16bee..b5e8b4f 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,77 +1,99 @@\n-from aiohttp import web \n-import aiohttp \n+\n+\n+\n+\n+\n+from aiohttp import web\n+import aiohttp\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n from urllib.parse import urljoin\n-import pathlib \n-import uuid \n-import imgkit \n+import pathlib\n+import uuid\n+import imgkit\n import asyncio\n import zlib\n-import io \n+import io\n \n async def crc32(data):\n     try:\n         data = data.encode()\n     except:\n-        pass \n-    result = \"crc32\" + str(zlib.crc32(data))\n-    return result \n+        pass\n+    return \"crc32\" + str(zlib.crc32(data))\n \n-async def get_file(name,suffix=\".cache\"):\n+async def get_file(name, suffix=\".cache\"):\n     name = await crc32(name)\n     path = pathlib.Path(\".\").joinpath(\"cache\")\n     if not path.exists():\n-        path.mkdir(parents=True,exist_ok=True)\n-    path = path.joinpath(name + suffix)\n-    return path\n-\n-\n+        path.mkdir(parents=True, exist_ok=True)\n+    return path.joinpath(name + suffix)\n \n async def public_touch(name=None):\n-    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4())+name)\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n     path.open(\"wb\").close()\n-    return path \n+    return path\n \n async def create_site_photo(url):\n     loop = asyncio.get_event_loop()\n     if not url.startswith(\"https\"):\n-    output_path = await get_file(\"site-screenshot-\" + url,\".png\")\n+    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n     \n     if output_path.exists():\n         return output_path\n     output_path.touch()\n+\n     def make_photo():\n         imgkit.from_url(url, output_path.absolute())\n-        return output_path \n+        return output_path\n \n-    return await loop.run_in_executor(None,make_photo)\n+    return await loop.run_in_executor(None, make_photo)\n \n async def repair_links(base_url, html_content):\n     soup = BeautifulSoup(html_content, \"html.parser\")\n     for tag in soup.find_all(['a', 'img', 'link']):\n+        if tag.has_attr('href') and not tag['href'].startswith(\"http\"):\n             tag['href'] = urljoin(base_url, tag['href'])\n+        if tag.has_attr('src') and not tag['src'].startswith(\"http\"):\n             tag['src'] = urljoin(base_url, tag['src'])\n-            print(\"Fixed: \",tag['src'])\n     return soup.prettify()\n \n async def is_html_content(content: bytes):\n     try:\n         content = content.decode(errors='ignore')\n     except:\n-        pass \n-    marks = ['<html','<img','<p','<span','<div']\n+        pass\n+    marks = ['<html', '<img', '<p', '<span', '<div']\n     try:\n         content = content.lower()\n         for mark in marks:\n             if mark in content:\n-                return True \n+                return True\n     except Exception as ex:\n         print(ex)\n-    return False \n+    return False\n \n @time_cache_async(120)\n async def get(url):\n@@ -79,5 +101,5 @@ async def get(url):\n         response = await session.get(url)\n         content = await response.text()\n         if await is_html_content(content):\n-            content = (await repair_links(url,content)).encode()\n+            content = (await repair_links(url, content)).encode()\n         return content\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nnew file mode 100644\nindex 0000000..f4beb2e\n--- /dev/null\n+++ b/src/snek/system/mapper.py\n@@ -0,0 +1,64 @@\n+\n+DEFAULT_LIMIT = 30\n+from snek.system.model import BaseModel\n+from snek.app import Application \n+import types\n+\n+class Mapper:\n+\n+    model_class:BaseModel = None \n+    default_limit:int = DEFAULT_LIMIT\n+    table_name:str = None \n+\n+    def __init__(self, app:Application, table_name:str, model_class:BaseModel):\n+        self.app = app \n+        \n+        if not self.model_class:\n+            raise ValueError(\"Mapper configuration error: model_class is not set.\")\n+        self.model_class = model_class \n+        \n+        self.table_name = table_name\n+        if not self.table_name:\n+            raise ValueError(\"Mapper configuration error: table_name is not set.\")\n+        self.default_limit = self.__class__.default_limit \n+    \n+    @property\n+    def db(self): \n+        return self.app.db\n+\n+    async def new(self):\n+        return self.model_class(mapper=self)\n+\n+    @property\n+    def table(self):\n+        return self.db[self.table_name]\n+\n+    async def get(self, uid:str=None, **kwargs) -> types.Optional[BaseModel]\n+        if uid:\n+            kwargs['uid'] = uid \n+        model = self.new()\n+        record = self.table.find_one(**kwargs)\n+        return self.model_class.from_record(mapper=self,record=record)\n+\n+    async def exists(self, **kwargs):\n+        return self.table.exists(**kwargs)\n+\n+    async def count(self, **kwargs) -> int:\n+        return self.table.count(**kwargs)\n+\n+    async def save(self, model:BaseModel) -> bool:\n+        record = model.record\n+        if not record.get('uid'):\n+            raise Exception(f\"Attempt to save without uid: {record}.\")\n+        return self.table.upsert(record,['uid'])\n+\n+    async def find(self, **kwargs) -> types.List[BaseModel]:\n+        if not kwargs.get(\"_limit\"):\n+            kwargs[\"_limit\"] = self.default_limit\n+        for record in self.table.find(**kwargs):\n+            yield self.model_class.from_record(mapper=self,record=record)\n+    \n+    async def delete(self, kwargs=None)-> int: \n+        if not kwargs or not isinstance(kwargs, dict):\n+            raise Exception(\"Can't execute delete with no filter.\")\n+        return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nnew file mode 100644\nindex 0000000..bded949\n--- /dev/null\n+++ b/src/snek/system/markdown.py\n@@ -0,0 +1,43 @@\n+\n+\n+from mistune import escape\n+from mistune import Markdown\n+from mistune import HTMLRenderer\n+from pygments import highlight\n+from pygments.lexers import get_lexer_by_name\n+from pygments.formatters import html\n+from pygments.styles import get_style_by_name\n+\n+\n+class MarkdownRenderer(HTMLRenderer):\n+    def __init__(self, app, template):\n+         self.template = template\n+         \n+         self.app = app\n+         self.env = self.app.jinja2_env\n+         formatter = html.HtmlFormatter()\n+         self.env.globals['highlight_styles'] = formatter.get_style_defs()\n+    def _escape(self,str):\n+    def block_code(self, code, lang=None,info=None):\n+        if not lang:\n+            lang = info\n+        if not lang:\n+            return f\"<div>{code}</div>\"\n+        lexer = get_lexer_by_name(lang, stripall=True)\n+        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n+        print(code, lang,info, flush=True)\n+        return highlight(code, lexer, formatter)\n+    def render(self):\n+        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n+        renderer = MarkdownRenderer(self.app,self.template)\n+        markdown = Markdown(renderer=renderer)\n+        return markdown(markdown_string)\n+\n+\n+async def render_markdown(app, markdown_string):\n+     renderer = MarkdownRenderer(app,None)\n+     markdown = Markdown(renderer=renderer)\n+     return markdown(markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 6b801ab..7fe457f 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,4 +1,12 @@\n-from aiohttp import web \n+\n+\n+\n+\n+from aiohttp import web\n \n @web.middleware\n async def no_cors_middleware(request, handler):\n@@ -7,16 +15,15 @@ async def no_cors_middleware(request, handler):\n     return response\n \n @web.middleware\n-async def cors_allow_middleware(request ,handler):\n+async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    return response \n+    return response\n \n @web.middleware\n async def cors_middleware(request, handler):\n     if request.method == \"OPTIONS\":\n         response = web.Response()\n         response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n@@ -24,7 +31,6 @@ async def cors_middleware(request, handler):\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n         return response\n \n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex a699a9e..eb490b3 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,36 +1,67 @@\n+\n+\n+\n+\n+\n import re\n import uuid\n-import json \n-from datetime import datetime , timezone \n+import json\n+from datetime import datetime, timezone\n from collections import OrderedDict\n-import copy \n+import copy\n \n TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n \n+\n def now():\n     return str(datetime.now(timezone.utc))\n \n+\n def add_attrs(**kwargs):\n     def decorator(func):\n         for key, value in kwargs.items():\n             setattr(func, key, value)\n-\n         return func\n     return decorator\n \n-def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**kwargs):\n-        def decorator(func):\n-            return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n+\n+def validate_attrs(required=False, min_length=None, max_length=None, regex=None, **kwargs):\n+    def decorator(func):\n+        return add_attrs(required=required, min_length=min_length, max_length=max_length, regex=regex, **kwargs)(func)\n+\n \n class Validator:\n     _index = 0\n+\n     @property\n     def value(self):\n-        return self._value \n+        return self._value\n \n-    @value.setter \n-    def value(self,val):\n-        self._value = json.loads(json.dumps(val,default=str))\n+    @value.setter\n+    def value(self, val):\n+        self._value = json.loads(json.dumps(val, default=str))\n \n     @property\n     def initial_value(self):\n@@ -39,48 +70,49 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, **kwargs):\n         self.index = Validator._index\n         Validator._index += 1\n-        self.required = required \n-        self.min_num = min_num \n+        self.required = required\n+        self.min_num = min_num\n         self.max_num = max_num\n-        self.min_length = min_length \n-        self.max_length = max_length \n-        self.regex = regex \n-        self._value = None \n+        self.min_length = min_length\n+        self.max_length = max_length\n+        self.regex = regex\n+        self._value = None\n         self.value = value\n-        print(\"xxxx\", value,flush=True) \n-        \n+        print(\"xxxx\", value, flush=True)\n+\n         self.kind = kind\n-        self.help_text = help_text \n+        self.help_text = help_text\n         self.__dict__.update(kwargs)\n-    @property \n+\n+    @property\n     def errors(self):\n         error_list = []\n         if self.value is None and self.required:\n             error_list.append(\"Field is required.\")\n-            return error_list \n-        \n+            return error_list\n+\n         if self.value is None:\n-            return error_list \n+            return error_list\n \n-        if self.kind == float or self.kind == int:\n+        if self.kind in [int, float]:\n             if self.min_num is not None and self.value < self.min_num:\n-                error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n+                error_list.append(f\"Field should be minimal {self.min_num}.\")\n             if self.max_num is not None and self.value > self.max_num:\n-                error_list.append(\"Field should be maximal {}.\".format(self.max_num))\n+                error_list.append(f\"Field should be maximal {self.max_num}.\")\n         if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n+            error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n         if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n-        print(self.regex, self.value,flush=True)\n-        if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n-            error_list.append(\"Invalid value.\".format(self.regex))\n-        if not self.kind is None and type(self.value) != self.kind:\n-            error_list.append(\"Invalid kind. It is supposed to be {}.\".format(self.kind))\n-        return error_list \n-        \n+            error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n+        print(self.regex, self.value, flush=True)\n+        if self.regex and self.value and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\")\n+        if self.kind and not isinstance(self.value, self.kind):\n+            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n+        return error_list\n+\n     def validate(self):\n         if self.errors:\n             raise ValueError(\"\\n\", self.errors)\n@@ -94,8 +126,6 @@ class Validator:\n         except ValueError:\n             return False\n \n-    \n-\n     def to_json(self):\n         return {\n             \"required\": self.required,\n@@ -109,25 +139,27 @@ class Validator:\n             \"help_text\": self.help_text,\n             \"errors\": self.errors,\n             \"is_valid\": self.is_valid,\n-            \"index\":self.index\n+            \"index\": self.index\n         }\n \n+\n class ModelField(Validator):\n \n     index = 1\n-    def __init__(self,name=None,save=True, *args, **kwargs):\n-        self.name = name \n+\n+    def __init__(self, name=None, save=True, *args, **kwargs):\n+        self.name = name\n         self.save = save\n         super().__init__(*args, **kwargs)\n \n     def to_json(self):\n         result = super().to_json()\n         result['name'] = self.name\n-        return result \n+        return result\n \n \n class CreatedField(ModelField):\n-    \n+\n     @property\n     def initial_value(self):\n         return now()\n@@ -136,67 +168,99 @@ class CreatedField(ModelField):\n         if not self.value:\n             self.value = now()\n \n+\n class UpdatedField(ModelField):\n \n     def update(self):\n         self.value = now()\n \n+\n class DeletedField(ModelField):\n \n     def update(self):\n         self.value = now()\n \n+\n class UUIDField(ModelField):\n-    \n-    @property \n+\n+    @property\n     def initial_value(self):\n         return str(uuid.uuid4())\n \n \n class BaseModel:\n+\n+    uid = UUIDField(name=\"uid\", required=True)\n+    created_at = CreatedField(name=\"created_at\", required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n+    updated_at = UpdatedField(name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\")\n+    deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+\n+    @classmethod \n+    def from_record(cls, record, mapper):\n+        model = cls.__new__()\n+        model.mapper = mapper \n+        model.record = record\n+        return model\n+\n+    @property \n+    def mapper(self):\n+        return self._mapper \n+\n+    @mapper.setter  \n+    def mapper(self, value):\n+        self._mapper = value  \n+\n+    @property \n+    def record(self):\n+        return {field.name: field.value for field in self.fields}\n     \n-    uid = UUIDField(name=\"uid\",required=True)\n-    created_at = CreatedField(name=\"created_at\",required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n-    updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n-    deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+    @record.setter \n+    def record(self, value):\n+        for key, value in self._record.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            field.value = value\n+        return self\n \n-   \n     def __init__(self, *args, **kwargs):\n         print(self.__dict__)\n         print(dir(self.__class__))\n+        self._mapper = None\n         self.fields = {}\n         for key in dir(self.__class__):\n-            obj = getattr(self.__class__,key)\n+            obj = getattr(self.__class__, key)\n \n-            if isinstance(obj,Validator):\n+            if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 print(\"JAAA\")\n-                self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+                self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n-            obj.value = value \n+        if isinstance(obj, Validator):\n+            obj.value = value\n \n     def __getattr__(self, key):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n+        if isinstance(obj, Validator):\n             print(\"HPAPP\")\n-            return obj.value \n+            return obj.value\n         return obj\n \n     def set_user_data(self, data):\n         for key, value in data.items():\n             field = self.fields.get(key)\n             if not field:\n-                continue \n+                continue\n             if value.get('name'):\n                 value = value.get('value')\n             field.value = value\n-           \n \n-    @property \n+    \n+\n+    @property\n     def is_valid(self):\n         for field in self.fields.values():\n             if not field.is_valid:\n@@ -205,46 +269,44 @@ class BaseModel:\n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n-            return obj.value \n+        if isinstance(obj, Validator):\n+            return obj.value\n \n     def __setattr__(self, key, value):\n-        obj = getattr(self,key)\n-        if isinstance(obj,Validator):\n+        obj = getattr(self, key)\n+        if isinstance(obj, Validator):\n             obj.value = value\n         else:\n-    @property \n+            self.__dict__[key] = value\n+\n+    @property\n     def record(self):\n         obj = self.to_json()\n         record = {}\n-        for key,value in obj.items():\n-            if getattr(self,key).save:\n+        for key, value in obj.items():\n+            if getattr(self, key).save:\n                 record[key] = value.get('value')\n         return record\n \n-    def to_json(self,encode=False):\n+    def to_json(self, encode=False):\n         model_data = OrderedDict({\n             \"uid\": self.uid.value,\n             \"created_at\": self.created_at.value,\n             \"updated_at\": self.updated_at.value,\n             \"deleted_at\": self.deleted_at.value\n         })\n-        \n-        for key,value in self.__dict__.items(): \n+\n+        for key, value in self.__dict__.items():\n             if key == \"record\":\n                 continue\n             value = self.__dict__[key]\n-            if hasattr(value,\"value\"):\n+            if hasattr(value, \"value\"):\n                 model_data[key] = value.to_json()\n         if encode:\n-            return json.dumps(model_data,indent=2)\n+            return json.dumps(model_data, indent=2)\n         return model_data\n \n+\n class FormElement(ModelField):\n \n     def __init__(self, place_holder=None, *args, **kwargs):\n@@ -252,15 +314,14 @@ class FormElement(ModelField):\n         self.place_holder = place_holder\n \n \n-\n class FormElement(ModelField):\n \n-    def __init__(self,place_holder=None, *args, **kwargs): \n-        self.place_holder = place_holder \n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        self.place_holder = place_holder\n         super().__init__(*args, **kwargs)\n \n     def to_json(self):\n         data = super().to_json()\n-        data[\"name\"] = self.name \n+        data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n-        return data \n+        return data\n\\ No newline at end of file\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nnew file mode 100644\nindex 0000000..b319f54\n--- /dev/null\n+++ b/src/snek/system/security.py\n@@ -0,0 +1,20 @@\n+import hashlib \n+\n+DEFAULT_SALT = b\"snekker-de-snek-\"\n+\n+async def hash(data,salt=DEFAULT_SALT):\n+    try:\n+        data = data.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass \n+    try:\n+        salt = salt.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    salted = salt + data\n+\n+    obj = hashlib.sha256(salted)\n+    return obj.hexdigest()\n+\n+async def verify(string:str, hashed:str):\n+    return await hash(string) == hashed \ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nnew file mode 100644\nindex 0000000..5a8b553\n--- /dev/null\n+++ b/src/snek/system/service.py\n@@ -0,0 +1,40 @@\n+\n+\n+\n+from snek.mapper import get_mapper\n+from snek.system.mapper import BaseMapper \n+from snek.model.user import UserModel\n+\n+class BaseService:\n+\n+    mapper_name:BaseMapper = None\n+\n+    def __init__(self, app):\n+        self.app = app \n+        if self.mapper_name:\n+            self.mapper = get_mapper(self.mapper_name, app=self.app)\n+        else:\n+            self.mapper = None \n+\n+    async def exists(self, **kwargs):\n+        return self.mapper.exists(**kwargs)\n+    \n+    async def count(self, **kwargs):\n+        return self.mapper.count(**kwargs)\n+\n+    async def new(self, **kwargs):\n+        return await self.mapper.new()\n+\n+    async def get(self, **kwargs):\n+        return await self.mapper.get(**kwargs)\n+    \n+    async def save(self, model:UserModel):\n+        if model.is_valid:\n+            return self.mapper.save(model) and True \n+        return False \n+    \n+    async def find(self, **kwargs):\n+        return await self.mapper.find(**kwargs)\n+    \n+    async def delete(self, **kwargs):\n+        return await self.mapper.delete(**kwargs)\n\\ No newline at end of file\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nnew file mode 100644\nindex 0000000..458aa20\n--- /dev/null\n+++ b/src/snek/system/view.py\n@@ -0,0 +1,38 @@\n+from aiohttp import web\n+\n+from snek.system.markdown import render_markdown \n+\n+class BaseView(web.View):\n+    \n+    @property \n+    def app(self):\n+        return self.request.app\n+    \n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    async def json_response(self, data):\n+        return web.json_response(data)\n+\n+    async def render_template(self, template_name, context=None):\n+        if template_name.endswith(\".md\"):\n+            response = await self.request.app.render_template(template_name,self.request,context)\n+            body = await render_markdown(self.app, response.body.decode())\n+            return web.Response(body=body,content_type=\"text/html\")\n+        return await self.request.app.render_template(template_name, self.request,context)\n+    \n+class BaseFormView(BaseView):\n+\n+    form = None \n+\n+    async def get(self):\n+        form = self.form()\n+        return await self.json_response(form.to_json())\n+       \n+    async def post(self):\n+        form = self.form()\n+        post = await self.request.json()\n+        form.set_user_data(post['form'])\n+        return await self.json_response(form.to_json())  \n+\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nnew file mode 100644\nindex 0000000..0f1b8a9\n--- /dev/null\n+++ b/src/snek/templates/about.html\n@@ -0,0 +1,7 @@\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+\n+<html-frame url=\"/about.md\"></html-frame>\n+<fancy-button text=\"Back\" url=\"/web.html\"></fancy-button>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nnew file mode 100644\nindex 0000000..134fbc9\n--- /dev/null\n+++ b/src/snek/templates/about.md\n@@ -0,0 +1,15 @@\n+\n+A snek is a danger noodle.\n+\n+I made several design choices: \n+- Implemented **the worst 3rd party markdown to html renderer ever**. See this nice *bullet list*.\n+ - Only password requirement is  thats it requires six characters. Users are responsibly for their own security. Snek is not so arrogant to determine if a password is strong enough. It's up to what user prefers. Snek does not have a forgot-my-password service tho.\n+ - Email is not required for registration. Email is (maybe) used in future for resetting password.\n+ - Homebrew made ORM framework based on dataset.\n+ - Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure.\n+ - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n+ - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 5b1bdf2..9f57467 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,9 +4,8 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\n-    <link rel=\"stylesheet\" href=\"/style.css\">\n-    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 1ee1f77..c2200fb 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -14,7 +14,8 @@\n     <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">Or</span>\n     <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n-\n+    <a href=\"/about.html\">Design choices</a>\n+    <a href=\"/web.html\">See web Application so far</a>\n   </div>\n </body>\n </html>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex c09ec70..c70d429 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/login-form\"></generic-form>\n+  <generic-form url=\"/login-form.json\"></generic-form>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 61da961..f0a82e0 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/register-form\"></generic-form>\n+  <generic-form url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nnew file mode 100644\nindex 0000000..593d5a9\n--- /dev/null\n+++ b/src/snek/view/about.py\n@@ -0,0 +1,14 @@\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class AboutHTMLView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"about.html\")\n+    \n+class AboutMDView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"about.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/base.py b/src/snek/view/base.py\ndeleted file mode 100644\nindex d962ee5..0000000\n--- a/src/snek/view/base.py\n+++ /dev/null\n@@ -1,31 +0,0 @@\n-from aiohttp import web \n-\n-class BaseView(web.View):\n-    \n-    @property \n-    def app(self):\n-        return self.request.app\n-    \n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    def json_response(self, data):\n-        return web.json_response(data)\n-\n-    def render_template(self, template_name, context=None):\n-        return self.request.app.render_template(template_name, self.request,context)\n-    \n-class BaseFormView(BaseView):\n-\n-    form = None \n-\n-    async def get(self):\n-        form = self.form()\n-        return self.json_response(form.to_json())\n-       \n-    async def post(self):\n-        form = self.form()\n-        post = await self.request.json()\n-        form.set_user_data(post['form'])\n-        return self.json_response(form.to_json())  \n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex a5d8b92..c7861fa 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseView\n+from snek.system.view import BaseView\n \n class IndexView(BaseView):\n \ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 3a3beaf..ffedc79 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,5 +1,5 @@\n from snek.form.register import RegisterForm\n-from snek.view.base import BaseView \n+from snek.system.view import BaseView\n \n class LoginView(BaseView):\n \ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 26527da..e9b6eac 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseFormView\n+from snek.system.view import BaseFormView\n from snek.form.login import LoginForm\n \n class LoginFormView(BaseFormView):\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 095b7a3..e3b3038 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseView \n+from snek.system.view import BaseView\n \n class RegisterView(BaseView):\n \ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 0ae7630..8099b01 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,5 +1,5 @@\n from snek.form.register import RegisterForm\n-from snek.view.base import BaseFormView \n+from snek.system.view import BaseFormView\n \n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/view.py b/src/snek/view/view.py\ndeleted file mode 100644\nindex ea642a3..0000000\n--- a/src/snek/view/view.py\n+++ /dev/null\n@@ -1,6 +0,0 @@\n-from snek.view.base import BaseView \n-\n-class WebView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"web.html\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nnew file mode 100644\nindex 0000000..b06563a\n--- /dev/null\n+++ b/src/snek/view/web.py\n@@ -0,0 +1,6 @@\n+from snek.system.view import BaseView\n+\n+class WebView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"web.html\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a", "diff": "commit 4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:05:47 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 5407a3b..a3e28f8 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -44,7 +44,9 @@ class FancyButton extends HTMLElement {\n         const me = this \n         this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n         this.buttonElement.addEventListener(\"click\",()=>{\n-            if(me.url){\n+            if(me.url == \"/back\" || me.url == \"/back/\"){\n+                window.history.back()\n+            }else if(me.url){\n                 window.location = me.url\n             }\n         })\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex 0f1b8a9..e403458 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -3,5 +3,5 @@\n {% block main %}\n \n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button text=\"Back\" url=\"/web.html\"></fancy-button>\n+<fancy-button text=\"Back\" url=\"/back\"></fancy-button>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1", "diff": "commit 0271e3f9719a4155fc5be37c36feca865932b0c1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:15:55 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex a3e28f8..5eec215 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -7,7 +7,18 @@ class FancyButton extends HTMLElement {\n     constructor(){\n         super()\n         this.attachShadow({mode:'open'})\n+    }\n+\n+    connectedCallback() {\n+\n         this.container = document.createElement('span')\n+        let size = this.getAttribute('size')\n+        console.info({GG:size})\n+        if(size == 'auto'){\n+           size = '1%' \n+        }else{\n+            size = '33%'\n+        }\n         this.styleElement = document.createElement(\"style\")\n         this.styleElement.innerHTML = `\n         :root {\n@@ -16,7 +27,7 @@ class FancyButton extends HTMLElement {\n     }\n         button {\n             width: var(--width);\n-            min-width: 33%;\n+            min-width: ${size};\n             padding: 10px;\n             border: none;\n@@ -26,19 +37,20 @@ class FancyButton extends HTMLElement {\n             font-weight: bold;\n             cursor: pointer;\n             transition: background-color 0.3s;\n+\n     }\n         button:hover {\n   }\n     `\n     this.container.appendChild(this.styleElement)\n         this.buttonElement = document.createElement('button')\n         this.container.appendChild(this.buttonElement)\n         this.shadowRoot.appendChild(this.container)\n-    }\n-\n-    connectedCallback() {\n+    \n         this.url = this.getAttribute('url');\n         this.value = this.getAttribute('value')\n         const me = this \ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex e403458..debba2a 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -3,5 +3,5 @@\n {% block main %}\n \n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button text=\"Back\" url=\"/back\"></fancy-button>\n+<fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated login and register button URLs", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0", "diff": "commit bda93e354f4691483dbbef29949672ab0989b7e0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:16:52 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex c2200fb..8157f3d 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -11,9 +11,9 @@\n <body>\n   <div class=\"registration-container\">\n     <h1>Snek</h1>\n-    <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n+    <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">Or</span>\n-    <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n+    <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n     <a href=\"/web.html\">See web Application so far</a>\n   </div>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and responsive design improvements", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b", "diff": "commit 757b67b78c2f396df7ac7b5706f98833aedfb85b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:47:19 2025 +0100\n\n    CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 363e4f1..263f1eb 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -4,6 +4,7 @@\n   box-sizing: border-box;\n }\n \n+\n body {\n   font-family: Arial, sans-serif;\n@@ -12,6 +13,10 @@ body {\n   display: flex;\n   flex-direction: column;\n   height: 100vh;\n+  min-width: 100%;\n+}\n+main {\n+  min-width: 100%;\n }\n \n header {\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 990fcf9..008a3ee 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,3 +1,27 @@\n+\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+\n+.center {\n+    padding: 30px;\n+    \n+    text-align: center;\n+    margin: auto auto;\n+    left: 25%;\n+    position: absolute;\n+  }\n+\n+  @media screen and (max-width: 500px) {\n+    .center {\n+        width: 100%;\n+        left: 0px;\n+    }\n+    \n+  }\n+\n h1 {\n     font-size: 2em;\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 9f57467..8637867 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -18,11 +18,6 @@\n \n     </header>\n     <main>\n-        <aside class=\"sidebar\">\n-        {% block sidebar %}\n-        \n-        {% endblock %}\n-    </aside>\n     {% block main %}\n     {% endblock %}\n </main>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex c70d429..2fe1a73 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,5 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/login-form.json\"></generic-form>\n+\n+   <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n+\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex f0a82e0..3668f13 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/register-form.json\"></generic-form>\n+  <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved UI consistency", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065", "diff": "commit 6ba6121988dae019d4c4c0a3b8592b443f094065\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 15:20:35 2025 +0100\n\n    CSS.\n\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex a87f7d3..910cd73 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -21,4 +21,5 @@ class LoginForm(Form):\n         text=\"Login\",\n         type=\"button\"\n     )\n+   \n \ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 0d5d4c9..75f800e 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -30,9 +30,7 @@ class HTMLFrame extends HTMLElement {\n           const parent = this\n           const markdownElement = document.createElement('div')\n           markdownElement.innerHTML = html\n-          document.body.appendChild(markdownElement)\n-          \n+          this.outerHTML = html\n         }else{\n           this.container.innerHTML = html;\n         }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 008a3ee..d2cda8c 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,18 +1,18 @@\n \n * {\n-    margin: 0;\n-    padding: 0;\n+  \n     box-sizing: border-box;\n   }\n \n-.center {\n+  .dialog {\n+\n+    border-radius: 10px;\n     padding: 30px;\n-    \n-    text-align: center;\n-    margin: auto auto;\n-    left: 25%;\n-    position: absolute;\n-  }\n+    width: 800px;\n+    margin: 30px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+}\n \n   @media screen and (max-width: 500px) {\n     .center {\n@@ -34,8 +34,15 @@ h2 {\n     margin-bottom: 20px;\n }\n body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    min-height: 100vh;\n \n }\n div {\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex debba2a..b68396e 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -1,7 +1,10 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n+<div class=\"dialog\">\n \n+    <fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n+\n+</div>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 2fe1a73..3e72928 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,7 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-\n+  <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n    <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 3668f13..b1540ea 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n+<fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+  \n   <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor login and register forms to use /login.json and /register.json endpoints.", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2", "diff": "commit c1eeacc0b415ef770418bb053d18ac0ffa4f64c2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:08:56 2025 +0100\n\n    Caching.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex deac5d3..db9ebf9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,7 +2,7 @@ import pathlib\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n-\n+from app.cache import time_cache_async\n from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n@@ -41,10 +41,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login-form.json\", LoginFormView)\n+        self.router.add_view(\"/login.json\", LoginFormView)\n         self.router.add_view(\"/register.html\", RegisterView)\n-        \n-        self.router.add_view(\"/register-form.json\", RegisterFormView)\n+        self.router.add_view(\"/register.json\", RegisterFormView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n@@ -65,6 +64,11 @@ class Application(BaseApplication):\n         return web.Response(\n             body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n         )\n+    \n+    \n+    @time_cache_async(60)\n+    async def render_template(self, template, request, context=None):\n+        return await super().render_template(template, request, context)\n \n \n app = Application()\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex bded949..93bc08c 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -8,7 +8,7 @@ from pygments import highlight\n from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n from pygments.styles import get_style_by_name\n-\n+import functools\n \n class MarkdownRenderer(HTMLRenderer):\n     def __init__(self, app, template):\n@@ -37,7 +37,11 @@ class MarkdownRenderer(HTMLRenderer):\n         return markdown(markdown_string)\n \n \n+@functools.cache\n+def render_markdown_sync(app, markdown_string):\n+    renderer = MarkdownRenderer(app,None)\n+    markdown = Markdown(renderer=renderer)\n+    return markdown(markdown_string)\n+\n async def render_markdown(app, markdown_string):\n-     renderer = MarkdownRenderer(app,None)\n-     markdown = Markdown(renderer=renderer)\n-     return markdown(markdown_string)\n\\ No newline at end of file\n+     return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex eb490b3..9a00186 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -81,8 +81,6 @@ class Validator:\n         self.regex = regex\n         self._value = None\n         self.value = value\n-        print(\"xxxx\", value, flush=True)\n-\n         self.kind = kind\n         self.help_text = help_text\n         self.__dict__.update(kwargs)\n@@ -106,7 +104,6 @@ class Validator:\n             error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n         if self.max_length is not None and len(self.value) > self.max_length:\n             error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n-        print(self.regex, self.value, flush=True)\n         if self.regex and self.value and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\")\n         if self.kind and not isinstance(self.value, self.kind):\n@@ -224,8 +221,6 @@ class BaseModel:\n         return self\n \n     def __init__(self, *args, **kwargs):\n-        print(self.__dict__)\n-        print(dir(self.__class__))\n         self._mapper = None\n         self.fields = {}\n         for key in dir(self.__class__):\n@@ -233,7 +228,6 @@ class BaseModel:\n \n             if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n-                print(\"JAAA\")\n                 self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n \n@@ -245,7 +239,6 @@ class BaseModel:\n     def __getattr__(self, key):\n         obj = self.__dict__.get(key)\n         if isinstance(obj, Validator):\n-            print(\"HPAPP\")\n             return obj.value\n         return obj\n \ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nindex 134fbc9..c28e9d0 100644\n--- a/src/snek/templates/about.md\n+++ b/src/snek/templates/about.md\n@@ -13,3 +13,57 @@ I made several design choices:\n  - Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure.\n  - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n  - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\n+\n+\n+A few examples of how the system framework works.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n\\ No newline at end of file\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 8637867..e00c886 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\n     <script src=\"/html-frame.js\"></script>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 3e72928..0a6bcc1 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -2,6 +2,6 @@\n \n {% block main %}\n   <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-   <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n+   <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex b1540ea..f8d1067 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -3,5 +3,5 @@\n {% block main %}\n <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n   \n-  <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n+  <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9", "diff": "commit 21ab5628b072320ae0851819116e57539b2397d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:09:10 2025 +0100\n\n    Caching.\n\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nnew file mode 100644\nindex 0000000..2992803\n--- /dev/null\n+++ b/src/snek/system/cache.py\n@@ -0,0 +1,17 @@\n+\n+import functools \n+\n+cache = functools.cache\n+\n+def async_cache(func):\n+    cache = {}\n+\n+    @functools.wraps(func)\n+    async def wrapper(*args):\n+        if args in cache:\n+            return cache[args]\n+        result = await func(*args)\n+        cache[args] = result\n+        return result\n+\n+    return wrapper\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Switch to Gunicorn and add API documentation", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb", "diff": "commit 8486c22c325ba358bd48766c518f7c7bd30059eb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:33:27 2025 +0100\n\n    Disabled cache.\n\ndiff --git a/compose.yml b/compose.yml\nindex 3b1f650..24e186c 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,5 +6,6 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n    \n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex db9ebf9..f25ffba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -7,6 +7,7 @@ from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n@@ -39,6 +40,9 @@ class Application(BaseApplication):\n         )\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/docs.html\", DocsHTMLView)\n+        self.router.add_view(\"/docs.md\", DocsMDView)\n+        \n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\n@@ -66,7 +70,7 @@ class Application(BaseApplication):\n         )\n     \n     \n-    @time_cache_async(60)\n     async def render_template(self, template, request, context=None):\n         return await super().render_template(template, request, context)\n \ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 93bc08c..489a3e5 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -9,6 +9,7 @@ from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n from pygments.styles import get_style_by_name\n import functools\n+from app.cache import time_cache_async\n \n class MarkdownRenderer(HTMLRenderer):\n     def __init__(self, app, template):\n@@ -28,7 +29,6 @@ class MarkdownRenderer(HTMLRenderer):\n         lexer = get_lexer_by_name(lang, stripall=True)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        print(code, lang,info, flush=True)\n         return highlight(code, lexer, formatter)\n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\n@@ -37,11 +37,12 @@ class MarkdownRenderer(HTMLRenderer):\n         return markdown(markdown_string)\n \n \n-@functools.cache\n+\n def render_markdown_sync(app, markdown_string):\n     renderer = MarkdownRenderer(app,None)\n     markdown = Markdown(renderer=renderer)\n     return markdown(markdown_string)\n \n+@time_cache_async(120)\n async def render_markdown(app, markdown_string):\n      return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nindex c28e9d0..0b43fb4 100644\n--- a/src/snek/templates/about.md\n+++ b/src/snek/templates/about.md\n@@ -14,56 +14,3 @@ I made several design choices:\n  - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n  - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\n \n-\n-A few examples of how the system framework works.\n-\n-```python\n-\n-new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n-)\n-```\n-\n-```python\n-from snek.system import security\n-\n-var1 = security.encrypt(\"data\")\n-var2 = security.encrypt(b\"data\")\n-\n-assert(var1 == var2)\n-```\n-\n-```python\n-from snek.system.view import BaseView \n-\n-class IndexView(BaseView):\n-    \n-    async def get(self):\n-        return await self.render(\"index.html\")\n-```\n-```python\n-from snek.system.view import BaseFormView\n-from snek.form.register import RegisterForm\n-\n-class RegisterFormView(BaseFormView):\n-    \n-    form = RegisterForm\n-```\n-```python\n-app.routes.add_view(\"/your-page.html\", YourViewClass)\n-```\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 8157f3d..ad02a6a 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -15,7 +15,8 @@\n     <span style=\"padding:10px;\">Or</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n-    <a href=\"/web.html\">See web Application so far</a>\n+    <a href=\"/web.html\">App preview</a>\n+    <a href=\"/docs.html\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Add documentation pages and views", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353", "diff": "commit aecd9f844ef0a277a55aa536db3336362e8db353\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:34:02 2025 +0100\n\n    Docs.\n\ndiff --git a/src/snek/templates/docs.html b/src/snek/templates/docs.html\nnew file mode 100644\nindex 0000000..21c0309\n--- /dev/null\n+++ b/src/snek/templates/docs.html\n@@ -0,0 +1,10 @@\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+<div class=\"dialog\">\n+\n+    <fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n+<html-frame url=\"/docs.md\"></html-frame>\n+\n+</div>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/docs.md b/src/snek/templates/docs.md\nnew file mode 100644\nindex 0000000..2cf60cb\n--- /dev/null\n+++ b/src/snek/templates/docs.md\n@@ -0,0 +1,53 @@\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nnew file mode 100644\nindex 0000000..e69d754\n--- /dev/null\n+++ b/src/snek/view/docs.py\n@@ -0,0 +1,15 @@\n+\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class DocsHTMLView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.html\")\n+    \n+class DocsMDView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.md\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog elements", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c", "diff": "commit be9489f939b3518f9c3a73b9e54ba0f9d34ae24c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:43:57 2025 +0100\n\n    Docs.\n\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex d2cda8c..63a28ed 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -19,6 +19,10 @@\n         width: 100%;\n         left: 0px;\n     }\n+    .dialog {\n+        width: 100%;\n+        left: 0px;\n+    }\n     \n   }"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field.", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb", "diff": "commit 2ba55f692dfc1b60ac55d514b182fb8834cb99bb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 21:19:03 2025 +0100\n\n    Finished register.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f25ffba..0cca574 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -4,6 +4,8 @@ from aiohttp import web\n from app.app import Application as BaseApplication\n from app.cache import time_cache_async\n from jinja_markdown2 import MarkdownExtension\n+from snek.mapper import get_mappers\n+from snek.service import get_services\n from snek.system import http\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -14,6 +16,7 @@ from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.web import WebView\n+from types import SimpleNamespace\n \n \n class Application(BaseApplication):\n@@ -29,6 +32,11 @@ class Application(BaseApplication):\n         )\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n+        self.setup_services()\n+\n+    def setup_services(self):\n+        self.services = SimpleNamespace(**get_services(app=self))\n+        self.mappers = SimpleNamespace(**get_mappers(app=self))\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 910cd73..0d97d41 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -22,4 +22,3 @@ class LoginForm(Form):\n         type=\"button\"\n     )\n    \n-\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 7dff3e4..4252bf1 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,10 +1,19 @@\n from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n \n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n class RegisterForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Register\")\n \n-    username = FormInputElement(\n+    username = UsernameField(\n         name=\"username\", \n         required=True,\n         min_length=2,\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 5b8671e..642028c 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -3,4 +3,4 @@ from snek.model.user import UserModel\n \n class UserMapper(BaseMapper):\n     table_name = \"user\"\n-    model: UserModel \n\\ No newline at end of file\n+    model_class = UserModel \n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex cde4b8c..a2b0cb1 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,13 +1,14 @@\n from snek.system.service import BaseService \n from snek.system import security \n \n-class UserService:\n+class UserService(BaseService):\n     mapper_name = \"user\"\n \n-    async def create_user(self, username, password):\n+    async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n+        model.email = email\n         model.username = username\n         model.password = await security.hash(password)\n         if await self.save(model):\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 58a67e2..a47c6db 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -280,7 +280,11 @@ class GenericForm extends HTMLElement {\n             if(e.detail.type == \"button\"){\n               if(e.detail.value == \"submit\")\n               {\n-                await me.validate()\n+                const isValid = await me.validate()\n+                if(isValid){\n+                  const isProcessed = await me.submit()\n+                  console.info({processed:isProcessed})\n+                }\n               }\n             }\n               \n@@ -294,13 +298,15 @@ class GenericForm extends HTMLElement {\n     async validate(){\n       const url = this.getAttribute(\"url\")\n       const me = this\n-      const response = await fetch(url,{\n+      let response = await fetch(url,{\n         method: 'POST',\n         headers: {\n           'Content-Type': 'application/json'\n         },\n         body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n       });\n+\n+      \n       const form = await response.json()\n       Object.values(form.fields).forEach(field=>{\n         if(!me.form.fields[field.name])\n@@ -320,6 +326,22 @@ class GenericForm extends HTMLElement {\n         console.info(field.errors)\n         me.fields[field.name].setErrors(field.errors)\n       })\n+      console.info({XX:form})\n+      return form['is_valid']\n+    }\n+    async submit(){\n+      const me = this \n+      const url = me.getAttribute(\"url\")\n+      const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify({\"action\":\"submit\", \"form\":me.form})\n+      });\n+      return await response.json()\n+    \n     }\n+    \n   }\n   customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex f9ebebb..82091b6 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -35,8 +35,8 @@ class HTMLElement(model.ModelField):\n         self.html = html\n         super().__init__(name=name, *args, **kwargs)\n \n-    def to_json(self):\n-        result = super().to_json()\n+    async def to_json(self):\n+        result = await super().to_json()\n         result['text'] = self.text\n         result['id'] = self.id\n         result['html'] = self.html\n@@ -53,8 +53,8 @@ class FormInputElement(FormElement):\n         self.place_holder = place_holder\n         self.type = type\n \n-    def to_json(self):\n-        data = super().to_json()\n+    async def to_json(self):\n+        data = await super().to_json()\n         data[\"place_holder\"] = self.place_holder\n         data[\"type\"] = self.type\n         return data\n@@ -66,31 +66,36 @@ class FormButtonElement(FormElement):\n class Form(model.BaseModel):\n     @property\n     def html_elements(self):\n-        json_elements = super().to_json()\n         return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n     def set_user_data(self, data):\n         return super().set_user_data(data.get('fields'))\n \n-    def to_json(self, encode=False):\n-        elements = super().to_json()\n+    async def to_json(self, encode=False):\n+        elements = await super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n+            if element == 'is_valid':\n+                continue \n             field = getattr(self, element)\n             if isinstance(field, HTMLElement):\n                 try:\n                     html_elements[element] = elements[element]\n                 except KeyError:\n                     pass\n-        return dict(fields=html_elements, is_valid=self.is_valid, errors=self.errors)\n+\n+        is_valid = all(field['is_valid'] for field in html_elements.values())\n+        return dict(fields=html_elements, is_valid=is_valid, errors=await self.errors)\n \n     @property\n-    def errors(self):\n+    async def errors(self):\n         result = []\n         for field in self.html_elements:\n-            result += field.errors\n+            result += await field.errors\n         return result\n \n     @property\n-    def is_valid(self):\n-        return all(element.is_valid for element in self.html_elements)\n\\ No newline at end of file\n+    async def is_valid(self):\n+        return False\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex f4beb2e..667aad1 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,25 +1,19 @@\n \n DEFAULT_LIMIT = 30\n+import typing\n from snek.system.model import BaseModel\n-from snek.app import Application \n+\n import types\n \n-class Mapper:\n+class BaseMapper:\n \n     model_class:BaseModel = None \n     default_limit:int = DEFAULT_LIMIT\n     table_name:str = None \n \n-    def __init__(self, app:Application, table_name:str, model_class:BaseModel):\n+    def __init__(self, app):\n         self.app = app \n         \n-        if not self.model_class:\n-            raise ValueError(\"Mapper configuration error: model_class is not set.\")\n-        self.model_class = model_class \n-        \n-        self.table_name = table_name\n-        if not self.table_name:\n-            raise ValueError(\"Mapper configuration error: table_name is not set.\")\n         self.default_limit = self.__class__.default_limit \n     \n     @property\n@@ -33,12 +27,12 @@ class Mapper:\n     def table(self):\n         return self.db[self.table_name]\n \n-    async def get(self, uid:str=None, **kwargs) -> types.Optional[BaseModel]\n+    async def get(self, uid:str=None, **kwargs) -> BaseModel:\n         if uid:\n             kwargs['uid'] = uid \n         model = self.new()\n         record = self.table.find_one(**kwargs)\n-        return self.model_class.from_record(mapper=self,record=record)\n+        return await self.model_class.from_record(mapper=self,record=record)\n \n     async def exists(self, **kwargs):\n         return self.table.exists(**kwargs)\n@@ -47,16 +41,16 @@ class Mapper:\n         return self.table.count(**kwargs)\n \n     async def save(self, model:BaseModel) -> bool:\n-        record = model.record\n+        record = await model.record\n         if not record.get('uid'):\n             raise Exception(f\"Attempt to save without uid: {record}.\")\n         return self.table.upsert(record,['uid'])\n \n-    async def find(self, **kwargs) -> types.List[BaseModel]:\n+    async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield self.model_class.from_record(mapper=self,record=record)\n+            yield await self.model_class.from_record(mapper=self,record=record)\n     \n     async def delete(self, kwargs=None)-> int: \n         if not kwargs or not isinstance(kwargs, dict):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 9a00186..0d700ff 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -70,9 +70,11 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, **kwargs):\n+    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, app=None, model=None, **kwargs):\n         self.index = Validator._index\n         Validator._index += 1\n+        self.app = app\n+        self.model = model\n         self.required = required\n         self.min_num = min_num\n         self.max_num = max_num\n@@ -86,7 +88,7 @@ class Validator:\n         self.__dict__.update(kwargs)\n \n     @property\n-    def errors(self):\n+    async def errors(self):\n         error_list = []\n         if self.value is None and self.required:\n             error_list.append(\"Field is required.\")\n@@ -110,20 +112,23 @@ class Validator:\n             error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n         return error_list\n \n-    def validate(self):\n-        if self.errors:\n-            raise ValueError(\"\\n\", self.errors)\n+    async def validate(self):\n+        errors = await self.errors\n+        if errors:\n+            raise ValueError(f\"Errors: {errors}.\")\n         return True\n \n     @property\n-    def is_valid(self):\n+    async def is_valid(self):\n         try:\n-            self.validate()\n+            await self.validate()\n             return True\n         except ValueError:\n             return False\n \n-    def to_json(self):\n+    async def to_json(self):\n+        errors = await self.errors\n+        is_valid = await self.is_valid\n         return {\n             \"required\": self.required,\n             \"min_num\": self.min_num,\n@@ -134,8 +139,8 @@ class Validator:\n             \"value\": self.value,\n             \"kind\": str(self.kind),\n             \"help_text\": self.help_text,\n-            \"errors\": self.errors,\n-            \"is_valid\": self.is_valid,\n+            \"errors\": errors,\n+            \"is_valid\": is_valid,\n             \"index\": self.index\n         }\n \n@@ -149,8 +154,8 @@ class ModelField(Validator):\n         self.save = save\n         super().__init__(*args, **kwargs)\n \n-    def to_json(self):\n-        result = super().to_json()\n+    async def to_json(self):\n+        result = await super().to_json()\n         result['name'] = self.name\n         return result\n \n@@ -193,7 +198,7 @@ class BaseModel:\n     deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n \n     @classmethod \n-    def from_record(cls, record, mapper):\n+    async def from_record(cls, record, mapper):\n         model = cls.__new__()\n         model.mapper = mapper \n         model.record = record\n@@ -230,6 +235,8 @@ class BaseModel:\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n+                self.fields[key].model = self\n+                self.fields[key].app = kwargs.get('app')\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -254,11 +261,9 @@ class BaseModel:\n     \n \n     @property\n-    def is_valid(self):\n-        for field in self.fields.values():\n-            if not field.is_valid:\n-                return False\n-        return True\n+    async def is_valid(self):\n+        return all([await field.is_valid for field in self.fields.values()])\n+        \n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -273,28 +278,31 @@ class BaseModel:\n             self.__dict__[key] = value\n \n     @property\n-    def record(self):\n-        obj = self.to_json()\n+    async def record(self):\n+        obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\n+            if not isinstance(value, dict) or not 'value' in value:\n+                continue\n             if getattr(self, key).save:\n                 record[key] = value.get('value')\n         return record\n \n-    def to_json(self, encode=False):\n+    async def to_json(self, encode=False):\n         model_data = OrderedDict({\n             \"uid\": self.uid.value,\n             \"created_at\": self.created_at.value,\n             \"updated_at\": self.updated_at.value,\n-            \"deleted_at\": self.deleted_at.value\n+            \"deleted_at\": self.deleted_at.value,\n+            \"is_valid\": await self.is_valid\n         })\n \n-        for key, value in self.__dict__.items():\n+        for key, value in self.fields.items():\n             if key == \"record\":\n                 continue\n             value = self.__dict__[key]\n             if hasattr(value, \"value\"):\n-                model_data[key] = value.to_json()\n+                model_data[key] = await value.to_json()\n         if encode:\n             return json.dumps(model_data, indent=2)\n         return model_data\n@@ -313,8 +321,8 @@ class FormElement(ModelField):\n         self.place_holder = place_holder\n         super().__init__(*args, **kwargs)\n \n-    def to_json(self):\n-        data = super().to_json()\n+    async def to_json(self):\n+        data = await super().to_json()\n         data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n         return data\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 5a8b553..d970b5f 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -17,10 +17,10 @@ class BaseService:\n             self.mapper = None \n \n     async def exists(self, **kwargs):\n-        return self.mapper.exists(**kwargs)\n+        return await self.count(**kwargs) > 0\n     \n     async def count(self, **kwargs):\n-        return self.mapper.count(**kwargs)\n+        return await self.mapper.count(**kwargs)\n \n     async def new(self, **kwargs):\n         return await self.mapper.new()\n@@ -29,9 +29,9 @@ class BaseService:\n         return await self.mapper.get(**kwargs)\n     \n     async def save(self, model:UserModel):\n-        if model.is_valid:\n-            return self.mapper.save(model) and True \n-        return False \n+        return await self.mapper.save(model) and True \n+         \n     \n     async def find(self, **kwargs):\n         return await self.mapper.find(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 458aa20..3b53f33 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -27,12 +27,21 @@ class BaseFormView(BaseView):\n     form = None \n \n     async def get(self):\n-        form = self.form()\n-        return await self.json_response(form.to_json())\n+        form = self.form(app=self.app)\n+        \n+        return await self.json_response(await form.to_json())\n        \n     async def post(self):\n-        form = self.form()\n+        form = self.form(app=self.app)\n         post = await self.request.json()\n         form.set_user_data(post['form'])\n-        return await self.json_response(form.to_json())  \n+        result = await form.to_json()\n+        if post.get('action') == 'validate':\n+            pass\n+        if post.get('action') == 'submit' and result['is_valid']:\n+            await self.submit(form)\n+        return await self.json_response(result) \n \n+    async def submit(self,model=None):\n+        print(\"Submit sucess\")\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 8099b01..8cb6567 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -2,4 +2,8 @@ from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n class RegisterFormView(BaseFormView):\n-    form = RegisterForm\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(form.email.value,form.username.value,form.password.value)\n+        print(\"SUBMITTED:\",result)\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation subapp and updated links", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14", "diff": "commit 18b76ebd5e2f11451db04800d426a16b1ef1dd14\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:33:36 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0cca574..78c0e2b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,11 +2,12 @@ import pathlib\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n+from snek.docs.app import Application as DocsApplication\n from app.cache import time_cache_async\n-from jinja_markdown2 import MarkdownExtension\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n+from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n@@ -59,6 +60,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n+        self.add_subapp(\"/docs\", DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")))\n+\n     async def handle_test(self, request):\n \n         return await self.render_template(\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 489a3e5..fcffe40 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,6 +1,7 @@\n \n \n+from types import SimpleNamespace\n from mistune import escape\n from mistune import Markdown\n from mistune import HTMLRenderer\n@@ -12,6 +13,8 @@ import functools\n from app.cache import time_cache_async\n \n class MarkdownRenderer(HTMLRenderer):\n+\n+    _allow_harmful_protocols = True\n     def __init__(self, app, template):\n          self.template = template\n          \n@@ -45,4 +48,29 @@ def render_markdown_sync(app, markdown_string):\n \n @time_cache_async(120)\n async def render_markdown(app, markdown_string):\n-     return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\n+     return render_markdown_sync(app,markdown_string)\n+\n+from jinja2 import nodes, TemplateSyntaxError\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n+\n+class MarkdownExtension(Extension):\n+    tags = {'markdown'}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(MarkdownExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const('')]\n+        body = ''\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements(['name:endmarkdown'], drop_needle=True)\n+        return nodes.CallBlock(self.call_method('_to_html', md_file), [], [], body).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return render_markdown_sync(self.app,caller())\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex ad02a6a..c6d57df 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -16,7 +16,7 @@\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n     <a href=\"/web.html\">App preview</a>\n-    <a href=\"/docs.html\">API docs</a>\n+    <a href=\"/docs/docs/\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f", "diff": "commit 9b93403a93ac0b03a57fb5dc10db5c35349c4d6f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:35:44 2025 +0100\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 78c0e2b..ab19f42 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,9 +1,10 @@\n import pathlib\n+from types import SimpleNamespace\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n+\n from snek.docs.app import Application as DocsApplication\n-from app.cache import time_cache_async\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n@@ -17,7 +18,6 @@ from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.web import WebView\n-from types import SimpleNamespace\n \n \n class Application(BaseApplication):\n@@ -51,7 +51,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-        \n+\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\n@@ -60,7 +60,10 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n-        self.add_subapp(\"/docs\", DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")))\n+        self.add_subapp(\n+            \"/docs\",\n+            DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n+        )\n \n     async def handle_test(self, request):\n \n@@ -79,9 +82,8 @@ class Application(BaseApplication):\n         return web.Response(\n             body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n         )\n-    \n-    \n+\n     async def render_template(self, template, request, context=None):\n         return await super().render_template(template, request, context)\n \ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 0d97d41..3d6d9a7 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,24 +1,27 @@\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n \n class LoginForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Login\")\n \n     username = FormInputElement(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n         place_holder=\"Username\",\n-        type=\"text\"\n+        type=\"text\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\",\n     )\n-    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n \n     action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Login\",\n-        type=\"button\"\n+        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n     )\n-   \ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 4252bf1..1384b8f 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,4 +1,5 @@\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n \n class UsernameField(FormInputElement):\n \n@@ -9,32 +10,35 @@ class UsernameField(FormInputElement):\n             result.append(\"Username is not available.\")\n         return result\n \n+\n class RegisterForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Register\")\n \n     username = UsernameField(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n         place_holder=\"Username\",\n-        type=\"text\"\n+        type=\"text\",\n     )\n     email = FormInputElement(\n         name=\"email\",\n         required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n         place_holder=\"Email address\",\n-        type=\"email\"\n+        type=\"email\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\",\n     )\n-    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n \n     action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Register\",\n-        type=\"button\"\n+        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n     )\n-\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex dc9e047..2b9b79f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,12 +1,12 @@\n-import functools \n+import functools\n+\n from snek.mapper.user import UserMapper\n \n-@functools.cache \n+\n+@functools.cache\n def get_mappers(app=None):\n-    return dict(\n-        user=UserMapper(app=app)\n+    return {\"user\": UserMapper(app=app)}\n \n-    )\n \n def get_mapper(name, app=None):\n-    return get_mappers(app=app)[name]\n\\ No newline at end of file\n+    return get_mappers(app=app)[name]\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 642028c..c388abc 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,6 +1,7 @@\n-from snek.system.mapper import BaseMapper\n from snek.model.user import UserModel\n+from snek.system.mapper import BaseMapper\n+\n \n class UserMapper(BaseMapper):\n     table_name = \"user\"\n-    model_class = UserModel \n\\ No newline at end of file\n+    model_class = UserModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 52af21a..081ae15 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,12 +1,12 @@\n-from snek.model.user import UserModel \n-import functools \n+import functools\n+\n+from snek.model.user import UserModel\n+\n \n @functools.cache\n def get_models():\n-    return dict(\n-        user=UserModel\n+    return {\"user\": UserModel}\n \n-    )\n \n def get_model(name):\n     return get_models()[name]\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 254b6c9..adb236b 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,9 +1,10 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n \n class UserModel(BaseModel):\n-    \n+\n     username = ModelField(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n@@ -12,8 +13,6 @@ class UserModel(BaseModel):\n     email = ModelField(\n         name=\"email\",\n         required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n-    password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n-\n-\n+    password = ModelField(name=\"password\", required=True, regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 4038f70..60fec76 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,12 +1,13 @@\n-from snek.service.user import UserService \n-import functools \n+import functools\n+\n+from snek.service.user import UserService\n+\n \n @functools.cache\n def get_services(app):\n \n-    return dict(\n-        user = UserService(app=app)\n+    return {\"user\": UserService(app=app)}\n+\n \n-    )\n def get_service(name, app=None):\n-    return get_services(app=app)[name]\n\\ No newline at end of file\n+    return get_services(app=app)[name]\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex a2b0cb1..5124640 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,5 +1,6 @@\n-from snek.system.service import BaseService \n-from snek.system import security \n+from snek.system import security\n+from snek.system.service import BaseService\n+\n \n class UserService(BaseService):\n     mapper_name = \"user\"\n@@ -12,6 +13,5 @@ class UserService(BaseService):\n         model.username = username\n         model.password = await security.hash(password)\n         if await self.save(model):\n-            return model \n+            return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\n-        \n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 2992803..5e275d9 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,8 +1,8 @@\n-\n-import functools \n+import functools\n \n cache = functools.cache\n \n+\n def async_cache(func):\n     cache = {}\n \n@@ -14,4 +14,4 @@ def async_cache(func):\n         cache[args] = result\n         return result\n \n-    return wrapper\n\\ No newline at end of file\n+    return wrapper\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 82091b6..f4cf2d3 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -5,17 +5,17 @@\n \n@@ -26,8 +26,19 @@\n \n from snek.system import model\n \n+\n class HTMLElement(model.ModelField):\n-    def __init__(self, id=None, tag=\"div\", name=None, html=None, class_name=None, text=None, *args, **kwargs):\n+    def __init__(\n+        self,\n+        id=None,\n+        tag=\"div\",\n+        name=None,\n+        html=None,\n+        class_name=None,\n+        text=None,\n+        *args,\n+        **kwargs,\n+    ):\n         self.tag = tag\n         self.text = text\n         self.id = id\n@@ -37,16 +48,18 @@ class HTMLElement(model.ModelField):\n \n     async def to_json(self):\n         result = await super().to_json()\n-        result['text'] = self.text\n-        result['id'] = self.id\n-        result['html'] = self.html\n-        result['class_name'] = self.class_name\n-        result['tag'] = self.tag\n+        result[\"text\"] = self.text\n+        result[\"id\"] = self.id\n+        result[\"html\"] = self.html\n+        result[\"class_name\"] = self.class_name\n+        result[\"tag\"] = self.tag\n         return result\n \n+\n class FormElement(HTMLElement):\n     pass\n \n+\n class FormInputElement(FormElement):\n     def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n         super().__init__(tag=\"input\", *args, **kwargs)\n@@ -59,25 +72,27 @@ class FormInputElement(FormElement):\n         data[\"type\"] = self.type\n         return data\n \n+\n class FormButtonElement(FormElement):\n     def __init__(self, tag=\"button\", *args, **kwargs):\n         super().__init__(tag=tag, *args, **kwargs)\n \n+\n class Form(model.BaseModel):\n     @property\n     def html_elements(self):\n         return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n     def set_user_data(self, data):\n-        return super().set_user_data(data.get('fields'))\n+        return super().set_user_data(data.get(\"fields\"))\n \n     async def to_json(self, encode=False):\n         elements = await super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n-            if element == 'is_valid':\n+            if element == \"is_valid\":\n-                continue \n+                continue\n             field = getattr(self, element)\n             if isinstance(field, HTMLElement):\n                 try:\n@@ -85,8 +100,12 @@ class Form(model.BaseModel):\n                 except KeyError:\n                     pass\n \n-        is_valid = all(field['is_valid'] for field in html_elements.values())\n-        return dict(fields=html_elements, is_valid=is_valid, errors=await self.errors)\n+        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n+        return {\n+            \"fields\": html_elements,\n+            \"is_valid\": is_valid,\n+            \"errors\": await self.errors,\n+        }\n \n     @property\n     async def errors(self):\n@@ -98,4 +117,4 @@ class Form(model.BaseModel):\n     @property\n     async def is_valid(self):\n-        return False\n\\ No newline at end of file\n+        return False\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex b5e8b4f..cd8a9b1 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -11,10 +11,10 @@\n@@ -24,17 +24,17 @@\n \n \n-from aiohttp import web\n-import aiohttp\n-from app.cache import time_cache_async\n-from bs4 import BeautifulSoup\n-from urllib.parse import urljoin\n+import asyncio\n import pathlib\n import uuid\n-import imgkit\n-import asyncio\n import zlib\n-import io\n+from urllib.parse import urljoin\n+\n+import aiohttp\n+import imgkit\n+from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n+\n \n async def crc32(data):\n     try:\n@@ -43,6 +43,7 @@ async def crc32(data):\n         pass\n     return \"crc32\" + str(zlib.crc32(data))\n \n+\n async def get_file(name, suffix=\".cache\"):\n     name = await crc32(name)\n     path = pathlib.Path(\".\").joinpath(\"cache\")\n@@ -50,17 +51,19 @@ async def get_file(name, suffix=\".cache\"):\n         path.mkdir(parents=True, exist_ok=True)\n     return path.joinpath(name + suffix)\n \n+\n async def public_touch(name=None):\n     path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n     path.open(\"wb\").close()\n     return path\n \n+\n async def create_site_photo(url):\n     loop = asyncio.get_event_loop()\n     if not url.startswith(\"https\"):\n     output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n-    \n+\n     if output_path.exists():\n         return output_path\n     output_path.touch()\n@@ -71,21 +74,23 @@ async def create_site_photo(url):\n \n     return await loop.run_in_executor(None, make_photo)\n \n+\n async def repair_links(base_url, html_content):\n     soup = BeautifulSoup(html_content, \"html.parser\")\n-    for tag in soup.find_all(['a', 'img', 'link']):\n-        if tag.has_attr('href') and not tag['href'].startswith(\"http\"):\n-            tag['href'] = urljoin(base_url, tag['href'])\n-        if tag.has_attr('src') and not tag['src'].startswith(\"http\"):\n-            tag['src'] = urljoin(base_url, tag['src'])\n+    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n+        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n+            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n+        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n+            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n     return soup.prettify()\n \n+\n async def is_html_content(content: bytes):\n     try:\n-        content = content.decode(errors='ignore')\n+        content = content.decode(errors=\"ignore\")\n     except:\n         pass\n-    marks = ['<html', '<img', '<p', '<span', '<div']\n+    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n     try:\n         content = content.lower()\n         for mark in marks:\n@@ -95,6 +100,7 @@ async def is_html_content(content: bytes):\n         print(ex)\n     return False\n \n+\n @time_cache_async(120)\n async def get(url):\n     async with aiohttp.ClientSession() as session:\n@@ -102,4 +108,4 @@ async def get(url):\n         content = await response.text()\n         if await is_html_content(content):\n             content = (await repair_links(url, content)).encode()\n-        return content\n\\ No newline at end of file\n+        return content\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 667aad1..f6c6200 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,23 +1,22 @@\n-\n DEFAULT_LIMIT = 30\n import typing\n+\n from snek.system.model import BaseModel\n \n-import types\n \n class BaseMapper:\n \n-    model_class:BaseModel = None \n-    default_limit:int = DEFAULT_LIMIT\n-    table_name:str = None \n+    model_class: BaseModel = None\n+    default_limit: int = DEFAULT_LIMIT\n+    table_name: str = None\n \n     def __init__(self, app):\n-        self.app = app \n-        \n-        self.default_limit = self.__class__.default_limit \n-    \n+        self.app = app\n+\n+        self.default_limit = self.__class__.default_limit\n+\n     @property\n-    def db(self): \n+    def db(self):\n         return self.app.db\n \n     async def new(self):\n@@ -27,12 +26,12 @@ class BaseMapper:\n     def table(self):\n         return self.db[self.table_name]\n \n-    async def get(self, uid:str=None, **kwargs) -> BaseModel:\n+    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n         if uid:\n-            kwargs['uid'] = uid \n-        model = self.new()\n+            kwargs[\"uid\"] = uid\n+        self.new()\n         record = self.table.find_one(**kwargs)\n-        return await self.model_class.from_record(mapper=self,record=record)\n+        return await self.model_class.from_record(mapper=self, record=record)\n \n     async def exists(self, **kwargs):\n         return self.table.exists(**kwargs)\n@@ -40,19 +39,19 @@ class BaseMapper:\n     async def count(self, **kwargs) -> int:\n         return self.table.count(**kwargs)\n \n-    async def save(self, model:BaseModel) -> bool:\n+    async def save(self, model: BaseModel) -> bool:\n         record = await model.record\n-        if not record.get('uid'):\n+        if not record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {record}.\")\n-        return self.table.upsert(record,['uid'])\n+        return self.table.upsert(record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield await self.model_class.from_record(mapper=self,record=record)\n-    \n-    async def delete(self, kwargs=None)-> int: \n+            yield await self.model_class.from_record(mapper=self, record=record)\n+\n+    async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\n         return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex fcffe40..23d0656 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,62 +1,65 @@\n-\n \n from types import SimpleNamespace\n-from mistune import escape\n-from mistune import Markdown\n-from mistune import HTMLRenderer\n+\n+from app.cache import time_cache_async\n+from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n-from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n-from pygments.styles import get_style_by_name\n-import functools\n-from app.cache import time_cache_async\n+from pygments.lexers import get_lexer_by_name\n+\n \n class MarkdownRenderer(HTMLRenderer):\n \n     _allow_harmful_protocols = True\n+\n     def __init__(self, app, template):\n-         self.template = template\n-         \n-         self.app = app\n-         self.env = self.app.jinja2_env\n-         formatter = html.HtmlFormatter()\n-         self.env.globals['highlight_styles'] = formatter.get_style_defs()\n-    def _escape(self,str):\n-    def block_code(self, code, lang=None,info=None):\n+        self.template = template\n+\n+        self.app = app\n+        self.env = self.app.jinja2_env\n+        formatter = html.HtmlFormatter()\n+        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n+\n+    def _escape(self, str):\n+\n+    def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n             return f\"<div>{code}</div>\"\n         lexer = get_lexer_by_name(lang, stripall=True)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         return highlight(code, lexer, formatter)\n+\n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\n-        renderer = MarkdownRenderer(self.app,self.template)\n+        renderer = MarkdownRenderer(self.app, self.template)\n         markdown = Markdown(renderer=renderer)\n         return markdown(markdown_string)\n \n \n-\n def render_markdown_sync(app, markdown_string):\n-    renderer = MarkdownRenderer(app,None)\n+    renderer = MarkdownRenderer(app, None)\n     markdown = Markdown(renderer=renderer)\n     return markdown(markdown_string)\n \n+\n @time_cache_async(120)\n async def render_markdown(app, markdown_string):\n-     return render_markdown_sync(app,markdown_string)\n+    return render_markdown_sync(app, markdown_string)\n \n-from jinja2 import nodes, TemplateSyntaxError\n+\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n+\n class MarkdownExtension(Extension):\n-    tags = {'markdown'}\n+    tags = {\"markdown\"}\n \n     def __init__(self, environment):\n         self.app = SimpleNamespace(jinja2_env=environment)\n@@ -64,13 +67,15 @@ class MarkdownExtension(Extension):\n \n     def parse(self, parser):\n         line_number = next(parser.stream).lineno\n-        md_file = [Const('')]\n-        body = ''\n+        md_file = [Const(\"\")]\n+        body = \"\"\n         try:\n             md_file = [parser.parse_expression()]\n         except TemplateSyntaxError:\n-            body = parser.parse_statements(['name:endmarkdown'], drop_needle=True)\n-        return nodes.CallBlock(self.call_method('_to_html', md_file), [], [], body).set_lineno(line_number)\n+            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return render_markdown_sync(self.app,caller())\n\\ No newline at end of file\n+        return render_markdown_sync(self.app, caller())\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 7fe457f..69fe378 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -8,12 +8,14 @@\n \n from aiohttp import web\n \n+\n @web.middleware\n async def no_cors_middleware(request, handler):\n     response = await handler(request)\n     response.headers.pop(\"Access-Control-Allow-Origin\", None)\n     return response\n \n+\n @web.middleware\n async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n@@ -22,12 +24,15 @@ async def cors_allow_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n     return response\n \n+\n @web.middleware\n async def cors_middleware(request, handler):\n     if request.method == \"OPTIONS\":\n         response = web.Response()\n         response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+        response.headers[\"Access-Control-Allow-Methods\"] = (\n+            \"GET, POST, PUT, DELETE, OPTIONS\"\n+        )\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n         return response\n \n@@ -35,4 +40,4 @@ async def cors_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    return response\n\\ No newline at end of file\n+    return response\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 0d700ff..b41f4ba 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -25,12 +25,12 @@\n \n \n+import copy\n+import json\n import re\n import uuid\n-import json\n-from datetime import datetime, timezone\n from collections import OrderedDict\n-import copy\n+from datetime import datetime, timezone\n \n TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n \n@@ -44,12 +44,21 @@ def add_attrs(**kwargs):\n         for key, value in kwargs.items():\n             setattr(func, key, value)\n         return func\n+\n     return decorator\n \n \n-def validate_attrs(required=False, min_length=None, max_length=None, regex=None, **kwargs):\n+def validate_attrs(\n+    required=False, min_length=None, max_length=None, regex=None, **kwargs\n+):\n     def decorator(func):\n-        return add_attrs(required=required, min_length=min_length, max_length=max_length, regex=regex, **kwargs)(func)\n+        return add_attrs(\n+            required=required,\n+            min_length=min_length,\n+            max_length=max_length,\n+            regex=regex,\n+            **kwargs,\n+        )(func)\n \n \n class Validator:\n@@ -70,7 +79,21 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, app=None, model=None, **kwargs):\n+    def __init__(\n+        self,\n+        required=False,\n+        min_num=None,\n+        max_num=None,\n+        min_length=None,\n+        max_length=None,\n+        regex=None,\n+        value=None,\n+        kind=None,\n+        help_text=None,\n+        app=None,\n+        model=None,\n+        **kwargs,\n+    ):\n         self.index = Validator._index\n         Validator._index += 1\n         self.app = app\n@@ -103,9 +126,13 @@ class Validator:\n             if self.max_num is not None and self.value > self.max_num:\n                 error_list.append(f\"Field should be maximal {self.max_num}.\")\n         if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n+            error_list.append(\n+                f\"Field should be minimal {self.min_length} characters long.\"\n+            )\n         if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n+            error_list.append(\n+                f\"Field should be maximal {self.max_length} characters long.\"\n+            )\n         if self.regex and self.value and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\")\n         if self.kind and not isinstance(self.value, self.kind):\n@@ -141,7 +168,7 @@ class Validator:\n             \"help_text\": self.help_text,\n             \"errors\": errors,\n             \"is_valid\": is_valid,\n-            \"index\": self.index\n+            \"index\": self.index,\n         }\n \n \n@@ -156,7 +183,7 @@ class ModelField(Validator):\n \n     async def to_json(self):\n         result = await super().to_json()\n-        result['name'] = self.name\n+        result[\"name\"] = self.name\n         return result\n \n \n@@ -193,30 +220,39 @@ class UUIDField(ModelField):\n class BaseModel:\n \n     uid = UUIDField(name=\"uid\", required=True)\n-    created_at = CreatedField(name=\"created_at\", required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n-    updated_at = UpdatedField(name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\")\n-    deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n-\n-    @classmethod \n+    created_at = CreatedField(\n+        name=\"created_at\",\n+        required=True,\n+        regex=TIMESTAMP_REGEX,\n+        place_holder=\"Created at\",\n+    )\n+    updated_at = UpdatedField(\n+        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n+    )\n+    deleted_at = DeletedField(\n+        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n+    )\n+\n+    @classmethod\n     async def from_record(cls, record, mapper):\n         model = cls.__new__()\n-        model.mapper = mapper \n+        model.mapper = mapper\n         model.record = record\n         return model\n \n-    @property \n+    @property\n     def mapper(self):\n-        return self._mapper \n+        return self._mapper\n \n-    @mapper.setter  \n+    @mapper.setter\n     def mapper(self, value):\n-        self._mapper = value  \n+        self._mapper = value\n \n-    @property \n+    @property\n     def record(self):\n         return {field.name: field.value for field in self.fields}\n-    \n-    @record.setter \n+\n+    @record.setter\n     def record(self, value):\n         for key, value in self._record.items():\n             field = self.fields.get(key)\n@@ -233,10 +269,12 @@ class BaseModel:\n \n             if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n-                self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n+                self.__dict__[key].value = kwargs.pop(\n+                    key, self.__dict__[key].initial_value\n+                )\n                 self.fields[key] = self.__dict__[key]\n                 self.fields[key].model = self\n-                self.fields[key].app = kwargs.get('app')\n+                self.fields[key].app = kwargs.get(\"app\")\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -254,16 +292,13 @@ class BaseModel:\n             field = self.fields.get(key)\n             if not field:\n                 continue\n-            if value.get('name'):\n-                value = value.get('value')\n+            if value.get(\"name\"):\n+                value = value.get(\"value\")\n             field.value = value\n \n-    \n-\n     @property\n     async def is_valid(self):\n         return all([await field.is_valid for field in self.fields.values()])\n-        \n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -282,20 +317,22 @@ class BaseModel:\n         obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\n-            if not isinstance(value, dict) or not 'value' in value:\n+            if not isinstance(value, dict) or \"value\" not in value:\n                 continue\n             if getattr(self, key).save:\n-                record[key] = value.get('value')\n+                record[key] = value.get(\"value\")\n         return record\n \n     async def to_json(self, encode=False):\n-        model_data = OrderedDict({\n-            \"uid\": self.uid.value,\n-            \"created_at\": self.created_at.value,\n-            \"updated_at\": self.updated_at.value,\n-            \"deleted_at\": self.deleted_at.value,\n-            \"is_valid\": await self.is_valid\n-        })\n+        model_data = OrderedDict(\n+            {\n+                \"uid\": self.uid.value,\n+                \"created_at\": self.created_at.value,\n+                \"updated_at\": self.updated_at.value,\n+                \"deleted_at\": self.deleted_at.value,\n+                \"is_valid\": await self.is_valid,\n+            }\n+        )\n \n         for key, value in self.fields.items():\n             if key == \"record\":\n@@ -325,4 +362,4 @@ class FormElement(ModelField):\n         data = await super().to_json()\n         data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n-        return data\n\\ No newline at end of file\n+        return data\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex b319f54..5449c50 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,12 +1,13 @@\n-import hashlib \n+import hashlib\n \n DEFAULT_SALT = b\"snekker-de-snek-\"\n \n-async def hash(data,salt=DEFAULT_SALT):\n+\n+async def hash(data, salt=DEFAULT_SALT):\n     try:\n         data = data.encode(errors=\"ignore\")\n     except AttributeError:\n-        pass \n+        pass\n     try:\n         salt = salt.encode(errors=\"ignore\")\n     except AttributeError:\n@@ -16,5 +17,6 @@ async def hash(data,salt=DEFAULT_SALT):\n     obj = hashlib.sha256(salted)\n     return obj.hexdigest()\n \n-async def verify(string:str, hashed:str):\n-    return await hash(string) == hashed \n+\n+async def verify(string: str, hashed: str):\n+    return await hash(string) == hashed\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d970b5f..1f9d601 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,24 +1,22 @@\n-\n-\n-\n from snek.mapper import get_mapper\n-from snek.system.mapper import BaseMapper \n from snek.model.user import UserModel\n+from snek.system.mapper import BaseMapper\n+\n \n class BaseService:\n \n-    mapper_name:BaseMapper = None\n+    mapper_name: BaseMapper = None\n \n     def __init__(self, app):\n-        self.app = app \n+        self.app = app\n         if self.mapper_name:\n             self.mapper = get_mapper(self.mapper_name, app=self.app)\n         else:\n-            self.mapper = None \n+            self.mapper = None\n \n     async def exists(self, **kwargs):\n         return await self.count(**kwargs) > 0\n-    \n+\n     async def count(self, **kwargs):\n         return await self.mapper.count(**kwargs)\n \n@@ -27,14 +25,13 @@ class BaseService:\n \n     async def get(self, **kwargs):\n         return await self.mapper.get(**kwargs)\n-    \n-    async def save(self, model:UserModel):\n+\n+    async def save(self, model: UserModel):\n-        return await self.mapper.save(model) and True \n-         \n-    \n+        return await self.mapper.save(model) and True\n+\n     async def find(self, **kwargs):\n         return await self.mapper.find(**kwargs)\n-    \n+\n     async def delete(self, **kwargs):\n-        return await self.mapper.delete(**kwargs)\n\\ No newline at end of file\n+        return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 3b53f33..1cf5329 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,13 +1,14 @@\n from aiohttp import web\n \n-from snek.system.markdown import render_markdown \n+from snek.system.markdown import render_markdown\n+\n \n class BaseView(web.View):\n-    \n-    @property \n+\n+    @property\n     def app(self):\n         return self.request.app\n-    \n+\n     @property\n     def db(self):\n         return self.app.db\n@@ -17,31 +18,36 @@ class BaseView(web.View):\n \n     async def render_template(self, template_name, context=None):\n         if template_name.endswith(\".md\"):\n-            response = await self.request.app.render_template(template_name,self.request,context)\n+            response = await self.request.app.render_template(\n+                template_name, self.request, context\n+            )\n             body = await render_markdown(self.app, response.body.decode())\n-            return web.Response(body=body,content_type=\"text/html\")\n-        return await self.request.app.render_template(template_name, self.request,context)\n-    \n+            return web.Response(body=body, content_type=\"text/html\")\n+        return await self.request.app.render_template(\n+            template_name, self.request, context\n+        )\n+\n+\n class BaseFormView(BaseView):\n \n-    form = None \n+    form = None\n \n     async def get(self):\n         form = self.form(app=self.app)\n-        \n+\n         return await self.json_response(await form.to_json())\n-       \n+\n     async def post(self):\n         form = self.form(app=self.app)\n         post = await self.request.json()\n-        form.set_user_data(post['form'])\n+        form.set_user_data(post[\"form\"])\n         result = await form.to_json()\n-        if post.get('action') == 'validate':\n+        if post.get(\"action\") == \"validate\":\n             pass\n-        if post.get('action') == 'submit' and result['is_valid']:\n+        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n             await self.submit(form)\n-        return await self.json_response(result) \n+        return await self.json_response(result)\n \n-    async def submit(self,model=None):\n+    async def submit(self, model=None):\n         print(\"Submit sucess\")\ndiff --git a/src/snek/templates/docs.md b/src/snek/templates/docs.md\nindex 2cf60cb..a42b7d5 100644\n--- a/src/snek/templates/docs.md\n+++ b/src/snek/templates/docs.md\n@@ -9,8 +9,7 @@ Currently only some details about the internal API are available.\n \n new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n+    username=\"retoor\", password=\"retoorded\"\n )\n ```\n \n@@ -23,15 +22,16 @@ var1 = security.encrypt(\"data\")\n var2 = security.encrypt(b\"data\")\n \n-assert(var1 == var2)\n+assert var1 == var2\n ```\n \n ```python\n-from snek.system.view import BaseView \n+from snek.system.view import BaseView\n+\n \n class IndexView(BaseView):\n-    \n+\n     async def get(self):\n@@ -40,11 +40,12 @@ class IndexView(BaseView):\n ```\n ```python\n-from snek.system.view import BaseFormView\n from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView\n+\n \n class RegisterFormView(BaseFormView):\n-    \n+\n     form = RegisterForm\n ```\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 593d5a9..762fc8e 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,5 +1,3 @@\n-\n-\n from snek.system.view import BaseView\n \n \n@@ -7,8 +5,9 @@ class AboutHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"about.html\")\n-    \n+\n+\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex e69d754..519a0eb 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,6 +1,3 @@\n-\n-\n-\n from snek.system.view import BaseView\n \n \n@@ -8,8 +5,9 @@ class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n-    \n+\n+\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex c7861fa..bd91dc8 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,5 +1,6 @@\n from snek.system.view import BaseView\n \n+\n class IndexView(BaseView):\n \n     async def get(self):\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex ffedc79..6566df9 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,13 +1,18 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseView\n \n+\n class LoginView(BaseView):\n \n     async def get(self):\n-        \n+        return await self.render_template(\n+            \"login.html\"\n+\n     async def post(self):\n         form = RegisterForm()\n         form.set_user_data(await self.request.post())\n         print(form.is_valid())\n+        return await self.render_template(\n+            \"login.html\", self.request\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex e9b6eac..576ddc6 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,5 +1,6 @@\n-from snek.system.view import BaseFormView\n from snek.form.login import LoginForm\n+from snek.system.view import BaseFormView\n+\n \n class LoginFormView(BaseFormView):\n-    form = LoginForm\n\\ No newline at end of file\n+    form = LoginForm\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex e3b3038..1186959 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,6 +1,7 @@\n from snek.system.view import BaseView\n \n+\n class RegisterView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"register.html\")  \n\\ No newline at end of file\n+        return await self.render_template(\"register.html\")\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 8cb6567..4c30169 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,9 +1,12 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n     async def submit(self, form):\n-        result = await self.app.services.user.register(form.email.value,form.username.value,form.password.value)\n-        print(\"SUBMITTED:\",result)\n\\ No newline at end of file\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        print(\"SUBMITTED:\", result)\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex b06563a..d42fcec 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,6 +1,7 @@\n from snek.system.view import BaseView\n \n+\n class WebView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"web.html\")\n\\ No newline at end of file\n+        return await self.render_template(\"web.html\")"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added initial documentation structure and content", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b", "diff": "commit b56371994f5d3f5c1aa5d63c28efd18856ea8e9b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:41:54 2025 +0100\n\n    Added docs.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nnew file mode 100644\nindex 0000000..087bb64\nBinary files /dev/null and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nnew file mode 100644\nindex 0000000..5e00d98\n--- /dev/null\n+++ b/src/snek/docs/app.py\n@@ -0,0 +1,33 @@\n+from app.app import Application as BaseApplication\n+import pathlib \n+from aiohttp import web\n+from snek.system.markdown import MarkdownExtension\n+\n+from snek.system.markdown import render_markdown \n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, path=None, *args,**kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path ,*args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension) \n+        \n+        self.router.add_get(\"/{tail:.*}\",self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info['tail'].strip(\"/\")\n+        if relative_path == '':\n+            relative_path = 'index.html'\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+        \n+        response = await self.render_template(str(document_path.relative_to(self.path)),request)\n+        return response\n+\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\nnew file mode 100644\nindex 0000000..e30a99d\n--- /dev/null\n+++ b/src/snek/docs/docs/api.html\n@@ -0,0 +1,61 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\nnew file mode 100644\nindex 0000000..7c53bec\n--- /dev/null\n+++ b/src/snek/docs/docs/base.html\n@@ -0,0 +1,116 @@\n+<html>\n+\n+<head>\n+    <style>{{ highlight_styles }}</style>\n+    <style>\n+        \n+        * {\n+\n+            box-sizing: border-box;\n+        }\n+\n+        .dialog {\n+\n+            border-radius: 10px;\n+            padding: 30px;\n+            width: 800px;\n+            margin: 30px;\n+            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        }\n+\n+        @media screen and (max-width: 500px) {\n+            .center {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+            .dialog {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+        }\n+\n+        h1 {\n+            font-size: 2em;\n+            margin-bottom: 20px;\n+        }\n+\n+        h2 {\n+            font-size: 1.4em;\n+            margin-bottom: 20px;\n+        }\n+\n+        html,body,main {\n+            font-family: Arial, sans-serif;\n+            line-height: 1.5;\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            min-height: 100vh;\n+            width: 100%;\n+        }\n+        article {\n+            max-width: 100%;\n+            width: 60%;\n+            padding: 30px;\n+            min-height: 100vh;\n+            word-break: break-all;\n+        }\n+        footer {\n+            position: fixed;\n+            width: 60%;\n+            text-align: center; \n+            bottom: 0;\n+            left: 20%;\n+        }\n+        a {\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  header {\n+\n+    text-align: left;\n+    width: 60%;\n+    padding: 30px;\n+  }\n+  header a {\n+    display: inline;\n+\n+  }\n+        div {\n+            text-align: left;\n+\n+        }\n+    </style>\n+</head>\n+\n+<body>\n+    <main>\n+        <header>\n+            <a href=\"/\">Snek</a>\n+            <a href=\"/docs/docs\">Docs</a>\n+        </header>\n+      <article>\n+            {% block main %}\n+            {% endblock %}\n+        </article>\n+    </main>\n+    <footer>\n+        {% markdown %}\n+        {% endmarkdown %}\n+    </footer>\n+</body>\n+\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\nnew file mode 100644\nindex 0000000..c83ee7f\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_javascript.html\n@@ -0,0 +1,17 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - generic-form.js \n+\n+It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n+```html \n+<generic-form url=\"/url-to-form-api\"></generic-form>\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\nnew file mode 100644\nindex 0000000..d7e67bc\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_python.html\n@@ -0,0 +1,92 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - `snek.system.form.Form`\n+ - `snek.system.form.HTMLElement`\n+ - `snek.system.form.FormInputElement`\n+ - `snek.system.form.FormButtonElement`\n+\n+Here is an example with custom validation. \n+This example contains a field that checks if user already exists. \n+If invalid, it adds an error message which automatically invalidates the field. \n+Handling of the error messages will automatically done client side.\n+\n+Forms are usaly located in `snek/form/[form name].py`.\n+\n+```python\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\"\n+    )\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+```\n+\n+\n+```python \n+data = dict(\n+    username=dict(value=\"retoor\"),\n+    password=dict(value=\"retoorded\")\n+)\n+form.set_user_data(data)\n+\n+is_valid = await form.is_valid\n+\n+key_value_values = await form.record \n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\nnew file mode 100644\nindex 0000000..8ced8ab\n--- /dev/null\n+++ b/src/snek/docs/docs/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Snek is a high customizable chat application. \n+It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n+\n+This documentation is under construction. Only the form API and the small introduction is a bit documented.\n+\n+[Small introduction / cheatsheet](/docs/docs/api.html)\n+\n+With the view classes of Snek you can render HTML and Markdown \n+\n+Snek's database model is based on Python dataset library. \n+Snek uses a model/mapper architecture build on top of that library.\n+\n+Snek does have his own components for creating and rendering forms. \n+All forms are made server side and client side is generated client side using a HTML component.\n+It's client side only one line to include a form that can validate and submit.\n+Validation is server side using REST. Page won't refresh.\n+[API Python](/docs/docs/form_api_python.html) \n+[API Javascript](/docs/docs/form_api_javascript.html)\n+\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559", "diff": "commit dae877113c76b6f0eded7b2d63ef921123a2b559\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:42:24 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 5e00d98..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,33 +1,43 @@\n-from app.app import Application as BaseApplication\n-import pathlib \n+import pathlib\n+\n from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n \n-from snek.system.markdown import render_markdown \n \n class Application(BaseApplication):\n \n-    def __init__(self, path=None, *args,**kwargs):\n+    def __init__(self, path=None, *args, **kwargs):\n         self.path = pathlib.Path(path)\n         template_path = self.path\n \n-        super().__init__(template_path=template_path ,*args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension) \n-        \n-        self.router.add_get(\"/{tail:.*}\",self.handle_document)\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n \n     async def handle_document(self, request):\n-        relative_path = request.match_info['tail'].strip(\"/\")\n-        if relative_path == '':\n-            relative_path = 'index.html'\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n         document_path = self.path.joinpath(relative_path)\n         if not document_path.exists():\n-            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n         if document_path.is_dir():\n             document_path = document_path.joinpath(\"index.html\")\n         if not document_path.exists():\n-            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n-        \n-        response = await self.render_template(str(document_path.relative_to(self.path)),request)\n-        return response\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n \n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41", "diff": "commit 5c69e14d7cfae8da4efab776165cc8e466edcc41\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:46:33 2025 +0100\n\n    Added session support.\n\ndiff --git a/cache/crc321300331366.cache b/cache/crc321300331366.cache\ndeleted file mode 100644\nindex e69de29..0000000\ndiff --git a/cache/crc322507170282.cache b/cache/crc322507170282.cache\ndeleted file mode 100644\nindex e69de29..0000000\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cc36846..71e90a7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -21,7 +21,8 @@ dependencies = [\n     \"gunicorn\",\n     \"imgkit\",\n     \"wkhtmltopdf\",\n-    \"jinja-markdown2\",\n-    \"mistune\"\n+    \"mistune\",\n+    \"aiohttp-session\",\n+    \"cryptography\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ab19f42..0abf24b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,23 @@ from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n+from snek.view.status import StatusView\n from snek.view.web import WebView\n \n+from aiohttp import web\n+from aiohttp_session import setup as session_setup, get_session as session_get, session_middleware\n+from aiohttp_session.cookie_storage import EncryptedCookieStorage\n+import base64\n+\n+SESSION_KEY = b'c79a0c5fda4b424189c427d28c9f7c34'\n+\n+@web.middleware\n+async def session_middleware(request, handler):\n+    setattr(request,\"session\", await session_get(request))\n+    response = await handler(request)\n+    return response\n+\n \n class Application(BaseApplication):\n \n@@ -31,10 +46,13 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n+        session_setup(self,EncryptedCookieStorage(SESSION_KEY))\n+        self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n         self.setup_services()\n \n+    \n     def setup_services(self):\n         self.services = SimpleNamespace(**get_services(app=self))\n         self.mappers = SimpleNamespace(**get_mappers(app=self))\n@@ -51,7 +69,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-\n+        self.router.add_view(\"/status.json\",StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nindex 087bb64..6f355a6 100644\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f06ee24..d8d3a8f 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -67,7 +67,7 @@ class Room {\n class InlineAppElement extends HTMLElement {\n     \n     constructor(){\n-        this.\n     }\n \n }\n@@ -77,6 +77,45 @@ class Page {\n \n }\n \n+class RESTClient {\n+    debug = true \n+    \n+    async get(url, params){\n+        params = params ? params : {} \n+        const encodedParams = new URLSearchParams(params);\n+        if(encodedParams)\n+            url += '?' + encodedParams\n+        const response = await fetch(url,{\n+            method: 'GET',\n+            headers: {\n+              'Content-Type': 'application/json'\n+            }\n+          });\n+          const result =  await response.json()\n+          if(this.debug){\n+            console.debug({url:url,params:params,result:result})\n+          }\n+        return result \n+    }\n+    async post(url, data) {\n+    const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify(data)\n+      });\n+\n+      const result =  await response.json()\n+          if(this.debug){\n+            console.debug({url:url,params:params,result:result})\n+          }\n+        return result\n+    }\n+}\n+\n+const rest = new RESTClient()\n+\n class App {\n     rooms = []\n     constructor() {\n@@ -84,7 +123,9 @@ class App {\n \n \n     }\n+    async post(url, data){\n \n+    }\n \n \n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex a47c6db..e775bf4 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -282,8 +282,10 @@ class GenericForm extends HTMLElement {\n               {\n                 const isValid = await me.validate()\n                 if(isValid){\n-                  const isProcessed = await me.submit()\n-                  console.info({processed:isProcessed})\n+                  const saveResult = await me.submit()\n+                  if(saveResult.redirect_url){\n+                    window.location.pathname = saveResult.redirect_url\n+                  }\n                 }\n               }\n             }\n@@ -315,7 +317,6 @@ class GenericForm extends HTMLElement {\n         if(!field.is_valid){\n           me.fields[field.name].setInvalid()\n           me.fields[field.name].setErrors(field.errors)\n-          console.info(field.name,\"is invalid\")\n         }else{\n           me.fields[field.name].setValid()\n         }\n@@ -323,10 +324,8 @@ class GenericForm extends HTMLElement {\n         me.fields[field.name].updateAttributes()\n       })\n       Object.values(form.fields).forEach(field=>{\n-        console.info(field.errors)\n         me.fields[field.name].setErrors(field.errors)\n       })\n-      console.info({XX:form})\n       return form['is_valid']\n     }\n     async submit(){\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex b41f4ba..98729b2 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -212,6 +212,14 @@ class DeletedField(ModelField):\n \n class UUIDField(ModelField):\n \n+    @property\n+    def value(self):\n+        return str(self._value)\n+    \n+    @value.setter\n+    def value(self,val):\n+         self._value = str(val)\n+\n     @property\n     def initial_value(self):\n         return str(uuid.uuid4())\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 1cf5329..8eebcb0 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -5,6 +5,13 @@ from snek.system.markdown import render_markdown\n \n class BaseView(web.View):\n \n+    login_required = False\n+\n+    async def _iter(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        return await super()._iter()\n+\n     @property\n     def app(self):\n         return self.request.app\n@@ -16,6 +23,10 @@ class BaseView(web.View):\n     async def json_response(self, data):\n         return web.json_response(data)\n \n+    @property \n+    def session(self):\n+        return self.request.session\n+\n     async def render_template(self, template_name, context=None):\n         if template_name.endswith(\".md\"):\n             response = await self.request.app.render_template(\n@@ -46,7 +57,8 @@ class BaseFormView(BaseView):\n             pass\n         if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n-            await self.submit(form)\n+            result = await self.submit(form)\n+            return await self.json_response(result)\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex e00c886..cb8fc5d 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <script src=\"/app.js\"></script>\n   <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0403e1b..5a636f0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -3,12 +3,13 @@\n <head>\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Dark Themed Chat Application</title>\n+  <title>Snek</title>\n+  <script src=\"/app.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n   <header>\n-    <div class=\"logo\">Molodetz Chat</div>\n+    <div class=\"logo\">Snek</div>\n     <nav>\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 4c30169..761c7c1 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -9,4 +9,8 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        print(\"SUBMITTED:\", result)\n+        self.request.session[\"uid\"] = result['uid']\n+        self.request.session[\"username\"] = result['usernmae']\n+        self.request.session[\"logged_in\"] = True\n+\n+        return dict(redirect_url=\"/web.html\")\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex d42fcec..63bacab 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -3,5 +3,7 @@ from snek.system.view import BaseView\n \n class WebView(BaseView):\n \n+    login_required = True\n+\n     async def get(self):\n         return await self.render_template(\"web.html\")"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16", "diff": "commit 352d2deb12a471bc90425961849fb2e92da3ab16\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:46:53 2025 +0100\n\n    Added status\n\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nnew file mode 100644\nindex 0000000..61e2c6c\n--- /dev/null\n+++ b/src/snek/view/status.py\n@@ -0,0 +1,9 @@\n+\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class StatusView(BaseView):\n+    async def get(self):\n+        return await self.json_response({\"status\": \"ok\", \"username\": self.session.get(\"username\"),\"logged_in\":self.session.get(\"username\") and True or False, \"uid\":self.session.get(\"uid\")})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-25", "line": "refactor: Improve session handling and data consistency", "commit": "12ca8e4296ca9693276422e524d7061685556ba0", "diff": "commit 12ca8e4296ca9693276422e524d7061685556ba0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:47:16 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0abf24b..584b321 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,12 @@ import pathlib\n from types import SimpleNamespace\n \n from aiohttp import web\n+from aiohttp_session import (\n+    get_session as session_get,\n+    session_middleware,\n+    setup as session_setup,\n+)\n+from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n \n from snek.docs.app import Application as DocsApplication\n@@ -20,17 +26,13 @@ from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \n-from aiohttp import web\n-from aiohttp_session import setup as session_setup, get_session as session_get, session_middleware\n-from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-import base64\n+SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n-SESSION_KEY = b'c79a0c5fda4b424189c427d28c9f7c34'\n \n @web.middleware\n async def session_middleware(request, handler):\n-    setattr(request,\"session\", await session_get(request))\n+    setattr(request, \"session\", await session_get(request))\n     response = await handler(request)\n     return response\n \n@@ -46,13 +48,12 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n-        session_setup(self,EncryptedCookieStorage(SESSION_KEY))\n+        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n         self.setup_services()\n \n-    \n     def setup_services(self):\n         self.services = SimpleNamespace(**get_services(app=self))\n         self.mappers = SimpleNamespace(**get_mappers(app=self))\n@@ -69,7 +70,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-        self.router.add_view(\"/status.json\",StatusView)\n+        self.router.add_view(\"/status.json\", StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 98729b2..7efde64 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -215,10 +215,10 @@ class UUIDField(ModelField):\n     @property\n     def value(self):\n         return str(self._value)\n-    \n+\n     @value.setter\n-    def value(self,val):\n-         self._value = str(val)\n+    def value(self, val):\n+        self._value = str(val)\n \n     @property\n     def initial_value(self):\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8eebcb0..1074615 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -23,7 +23,7 @@ class BaseView(web.View):\n     async def json_response(self, data):\n         return web.json_response(data)\n \n-    @property \n+    @property\n     def session(self):\n         return self.request.session\n \ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 761c7c1..89fa3ac 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -9,8 +9,8 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result['uid']\n-        self.request.session[\"username\"] = result['usernmae']\n+        self.request.session[\"uid\"] = result[\"uid\"]\n+        self.request.session[\"username\"] = result[\"usernmae\"]\n         self.request.session[\"logged_in\"] = True\n \n-        return dict(redirect_url=\"/web.html\")\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 61e2c6c..add86a6 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,9 +1,13 @@\n-\n-\n-\n from snek.system.view import BaseView\n \n \n class StatusView(BaseView):\n     async def get(self):\n-        return await self.json_response({\"status\": \"ok\", \"username\": self.session.get(\"username\"),\"logged_in\":self.session.get(\"username\") and True or False, \"uid\":self.session.get(\"uid\")})\n\\ No newline at end of file\n+        return await self.json_response(\n+            {\n+                \"status\": \"ok\",\n+                \"username\": self.session.get(\"username\"),\n+                \"logged_in\": self.session.get(\"username\") and True or False,\n+                \"uid\": self.session.get(\"uid\"),\n+            }\n+        )"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7", "diff": "commit bb6bcf41d1bb2132684b6251853f7d34e202a9f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 05:50:23 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 584b321..f26fc06 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -21,6 +21,7 @@ from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n+from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n@@ -68,6 +69,8 @@ class Application(BaseApplication):\n         )\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/logout.json\", LogoutView)\n+        self.router.add_view(\"/logout.html\", LogoutView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 3d6d9a7..2966053 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,11 +1,24 @@\n from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n \n \n+class AuthField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.model.password.value and self.model.username.value:\n+            if not await self.app.services.user.validate_login(\n+                self.model.username.value, self.model.password.value\n+            ):\n+                return [\"Invalid username or password\"]\n+        return result\n+\n+\n class LoginForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Login\")\n \n-    username = FormInputElement(\n+    username = AuthField(\n         name=\"username\",\n         required=True,\n         min_length=2,\n@@ -14,7 +27,7 @@ class LoginForm(Form):\n         place_holder=\"Username\",\n         type=\"text\",\n     )\n-    password = FormInputElement(\n+    password = AuthField(\n         name=\"password\",\n         required=True,\n         regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n@@ -25,3 +38,14 @@ class LoginForm(Form):\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n     )\n+\n+    @property\n+    async def is_valid(self):\n+        return all(\n+            [\n+                self[\"username\"],\n+                self[\"password\"],\n+                not await self.username.errors,\n+                not await self.password.errors,\n+            ]\n+        )\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 5124640..cfcd6b8 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -5,13 +5,23 @@ from snek.system.service import BaseService\n class UserService(BaseService):\n     mapper_name = \"user\"\n \n+    async def validate_login(self, username, password):\n+        model = await self.get(username=username)\n+        print(\"FOUND USER!\", model, flush=True)\n+        if not model:\n+            return False\n+        print(\"AU\", password, model.password.value, flush=True)\n+        if not await security.verify(password, model[\"password\"]):\n+            return False\n+        return True\n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n-        model.email = email\n-        model.username = username\n-        model.password = await security.hash(password)\n+        model.email.value = email\n+        model.username.value = username\n+        model.password.value = await security.hash(password)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex d8d3a8f..133cbcd 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -113,9 +113,98 @@ class RESTClient {\n         return result\n     }\n }\n-\n const rest = new RESTClient()\n \n+class EventHandler {\n+\n+    constructor(){\n+        this.subscribers = {}\n+    }\n+    addEventListener(type,handler){\n+        if(!this.subscribers[type])\n+            this.subscribers[type] = []\n+        this.subscribers[type].push(handler)\n+    }\n+    emit(type,...data){\n+        if(this.subscribers[type])\n+            this.subscribers[type].forEach(handler=>handler(...data))\n+    }\n+\n+}\n+\n+class Chat extends EventHandler {\n+\n+    constructor() {\n+        super()\n+        this._socket = null \n+        this._wait_connect = null \n+        this._promises = {}\n+    }\n+    connect(){\n+        if(this._wait_connect)\n+            return this._wait_connect\n+        \n+            const me = this \n+            return new Promise(async (resolve,reject)=>{\n+                me._wait_connect = resolve \n+                me._socket = new WebSocket(me._url)\n+                console.debug(\"Connecting..\")\n+                \n+                me._socket.onconnect = ()=>{\n+                    me._connected()\n+                    me._wait_socket(me)\n+                }\n+            }) \n+        \n+    }\n+    generateUniqueId() {\n+    }\n+    call(method,...args){\n+        const me = this \n+        return new Promise(async (resolve,reject)=>{\n+            try{\n+                const command = {method:method,args:args,message_id:me.generateUniqueId()}\n+                me._promises[command.message_id] = resolve\n+                await me._socket.send(JSON.stringify(command)) \n+               \n+            }catch(e){\n+                reject(e)\n+            }\n+        })\n+    }\n+    _connected() {\n+        const me = this \n+        this._socket.onmessage = (event) => {\n+            const message = JSON.parse(event.data)\n+            if(message.message_id && me._promises[message.message_id]){\n+                me._promises[message.message_id](message)\n+                delete me._promises[message.message_id]\n+            }else{\n+            me.emit(\"message\",me, message)\n+            }\n+        }\n+        this._socket.onclose = (event) => {\n+            me._wait_socket = null \n+            me._socket = null \n+            me.emit('close',me)\n+        }\n+    }\n+\n+    async privmsg(room, text) {\n+        await rest.post(\"/api/privmsg\",{\n+            room:room,\n+            text:text\n+        })\n+    }\n+\n+}\n+\n+\n class App {\n     rooms = []\n     constructor() {\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex f6c6200..489ff90 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -29,8 +29,14 @@ class BaseMapper:\n     async def get(self, uid: str = None, **kwargs) -> BaseModel:\n         if uid:\n             kwargs[\"uid\"] = uid\n-        self.new()\n         record = self.table.find_one(**kwargs)\n+        if not record:\n+            return None\n+        record = dict(record)\n+        model = await self.new()\n+        for key, value in record.items():\n+            model[key] = value\n+        return model\n         return await self.model_class.from_record(mapper=self, record=record)\n \n     async def exists(self, **kwargs):\n@@ -40,10 +46,9 @@ class BaseMapper:\n         return self.table.count(**kwargs)\n \n     async def save(self, model: BaseModel) -> bool:\n-        record = await model.record\n-        if not record.get(\"uid\"):\n-            raise Exception(f\"Attempt to save without uid: {record}.\")\n-        return self.table.upsert(record, [\"uid\"])\n+        if not model.record.get(\"uid\"):\n+            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 7efde64..ba3fc45 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -243,7 +243,7 @@ class BaseModel:\n \n     @classmethod\n     async def from_record(cls, record, mapper):\n-        model = cls.__new__()\n+        model = cls()\n         model.mapper = mapper\n         model.record = record\n         return model\n@@ -258,15 +258,15 @@ class BaseModel:\n \n     @property\n     def record(self):\n-        return {field.name: field.value for field in self.fields}\n+        return {key: field.value for key, field in self.fields.items()}\n \n     @record.setter\n-    def record(self, value):\n-        for key, value in self._record.items():\n+    def record(self, val):\n+        for key, value in val.items():\n             field = self.fields.get(key)\n             if not field:\n                 continue\n-            field.value = value\n+            self[key] = value\n         return self\n \n     def __init__(self, *args, **kwargs):\n@@ -321,7 +321,7 @@ class BaseModel:\n             self.__dict__[key] = value\n \n     @property\n-    async def record(self):\n+    async def recordz(self):\n         obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 5a636f0..ae0bb06 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,10 +11,10 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n+      <a href=\"/web.html\">Home</a>\n+      <a href=\"/logout.html\">Logout</a>\n     </nav>\n   </header>\n   <main>\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 576ddc6..f0efce9 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -4,3 +4,11 @@ from snek.system.view import BaseFormView\n \n class LoginFormView(BaseFormView):\n     form = LoginForm\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nnew file mode 100644\nindex 0000000..eb5c1ae\n--- /dev/null\n+++ b/src/snek/view/logout.py\n@@ -0,0 +1,27 @@\n+from aiohttp import web\n+\n+from snek.system.view import BaseView\n+\n+\n+class LogoutView(BaseView):\n+\n+    redirect_url = \"/\"\n+    login_required = True\n+\n+    async def get(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return web.HTTPFound(self.redirect_url)\n+\n+    async def post(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return await self.json_response({\"redirect_url\": self.redirect_url})"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and mapper initialization\n\nThis commit introduces several changes to improve the structure and initialization of services and mappers:\n\n-   **Centralized Initialization:** Services and mappers are now initialized in a single location (`src/snek/service/__init__.py` and `src/snek/mapper/__init__.py`), making it easier to manage and extend them.\n-   **Object-Based Services:** Services are now stored in a `SimpleNamespace` object, allowing for easier access and management of service instances.\n-   **Cache Integration:** Added a `Cache` class to improve performance by caching frequently accessed data.\n-   **New Mappers and Models:** Added mappers and models for `channel`, `channel_member`, and `channel_message` to support channel-based communication.\n-   **Service Refactoring:** Refactored the `UserService` to use the new `Cache` class and ensure a public channel is created upon user registration.\n-   **View Updates:** Updated the `LoginView` and `RegisterView` to use the new form classes and session management.\n-   **Code Cleanup:** Removed unused code and improved overall code readability.", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8", "diff": "commit b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 22:24:44 2025 +0100\n\n    Mappers and models.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f26fc06..bc0884a 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -14,6 +14,7 @@ from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n+from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -53,11 +54,9 @@ class Application(BaseApplication):\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n-        self.setup_services()\n-\n-    def setup_services(self):\n-        self.services = SimpleNamespace(**get_services(app=self))\n-        self.mappers = SimpleNamespace(**get_mappers(app=self))\n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n@@ -76,9 +75,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/status.json\", StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login.json\", LoginFormView)\n+        self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n-        self.router.add_view(\"/register.json\", RegisterFormView)\n+        self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 2b9b79f..1f29d73 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,11 +1,21 @@\n import functools\n+from types import SimpleNamespace\n \n+from snek.mapper.channel import ChannelMapper\n+from snek.mapper.channel_member import ChannelMemberMapper\n+from snek.mapper.channel_message import ChannelMessageMapper\n from snek.mapper.user import UserMapper\n+from snek.system.object import Object\n \n \n @functools.cache\n def get_mappers(app=None):\n-    return {\"user\": UserMapper(app=app)}\n+    return Object(\n+        **{\"user\": UserMapper(app=app),\n+            'channel_member': ChannelMemberMapper(app=app),\n+            'channel': ChannelMapper(app=app),\n+            'channel_message': ChannelMessageMapper(app=app)    \n+    })\n \n \n def get_mapper(name, app=None):\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nnew file mode 100644\nindex 0000000..6239dc8\n--- /dev/null\n+++ b/src/snek/mapper/channel.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel import ChannelModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMapper(BaseMapper):\n+    table_name = \"channel\"\n+    model_class = ChannelModel\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nnew file mode 100644\nindex 0000000..f0f62d6\n--- /dev/null\n+++ b/src/snek/mapper/channel_member.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_member import ChannelMemberModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMemberMapper(BaseMapper):\n+    table_name = \"channel_member\"\n+    model_class = ChannelMemberModel\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nnew file mode 100644\nindex 0000000..364c1ee\n--- /dev/null\n+++ b/src/snek/mapper/channel_message.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_message import ChannelMessageModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMessageMapper(BaseMapper):\n+    model_class = ChannelMessageModel\n+    table_name = \"channel_message\"\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 081ae15..ccb5289 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,11 +1,19 @@\n import functools\n \n+from snek.model.channel import ChannelModel\n+from snek.model.channel_member import ChannelMemberModel\n+from snek.model.channel_message import ChannelMessageModel\n from snek.model.user import UserModel\n+from snek.system.object import Object\n \n \n @functools.cache\n def get_models():\n-    return {\"user\": UserModel}\n+    return Object(**{\"user\": UserModel,\n+            \"channel_member\": ChannelMemberModel,\n+            \"channel\": ChannelModel,\n+            \"channel_message\": ChannelMessageModel})\n \n \n def get_model(name):\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nnew file mode 100644\nindex 0000000..50b1181\n--- /dev/null\n+++ b/src/snek/model/channel.py\n@@ -0,0 +1,11 @@\n+from snek.system.model import BaseModel, ModelField\n+\n+class ChannelModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True,kind=str)\n+    description = ModelField(name=\"description\", required=False,kind=str)\n+    tag = ModelField(name=\"tag\", required=False,kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True,kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True,kind=bool,value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True,kind=bool,value=True)\n+    index = ModelField(name=\"index\", required=True,kind=int,value=1000) \n+\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nnew file mode 100644\nindex 0000000..48131e4\n--- /dev/null\n+++ b/src/snek/model/channel_member.py\n@@ -0,0 +1,10 @@\n+from snek.system.model import BaseModel, ModelField\n+\n+class ChannelMemberModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True,kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n+    is_moderator = ModelField(name=\"is_moderator\", required=True,kind=bool,value=False)\n+    is_read_only = ModelField(name=\"is_read_only\", required=True,kind=bool,value=False)\n+    is_muted = ModelField(name=\"is_muted\", required=True,kind=bool,value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True,kind=bool,value=False)\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nnew file mode 100644\nindex 0000000..9e96307\n--- /dev/null\n+++ b/src/snek/model/channel_message.py\n@@ -0,0 +1,9 @@\n+\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class ChannelMessageModel(BaseModel):\n+    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n+    message = ModelField(name=\"message\", required=True,kind=str)\n\\ No newline at end of file\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nnew file mode 100644\nindex 0000000..0a5c294\n--- /dev/null\n+++ b/src/snek/model/notification.py\n@@ -0,0 +1,12 @@\n+\n+\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class NotificationModel(BaseModel):\n+    object_uid = ModelField(name=\"object_uid\", required=True)\n+    object_type = ModelField(name=\"object_type\", required=True)\n+    message = ModelField(name=\"message\", required=True)\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    read_at = ModelField(name=\"is_read\", required=True)\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex adb236b..97070c4 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -10,6 +10,13 @@ class UserModel(BaseModel):\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n     )\n+    nick = ModelField(\n+        name=\"nick\",\n+        required=False,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+    )\n     email = ModelField(\n         name=\"email\",\n         required=False,\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 60fec76..8457917 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,12 +1,21 @@\n import functools\n \n+from snek.service.channel import ChannelService\n from snek.service.user import UserService\n+from snek.service.channel_member import ChannelMemberService\n+from types import SimpleNamespace\n \n+from snek.system.object import Object\n \n @functools.cache\n def get_services(app):\n-\n-    return {\"user\": UserService(app=app)}\n+    return Object(\n+        **{\n+            \"user\": UserService(app=app),\n+            \"channel_member\": ChannelMemberService(app=app),\n+            'channel': ChannelService(app=app)\n+        }\n+)\n \n \n def get_service(name, app=None):\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nnew file mode 100644\nindex 0000000..9290baf\n--- /dev/null\n+++ b/src/snek/service/channel.py\n@@ -0,0 +1,31 @@\n+from snek.system.service import BaseService\n+\n+class ChannelService(BaseService):\n+    mapper_name = \"channel\"\n+\n+    async def create(self, label, created_by_uid,  description=None, tag=None, is_private=False, is_listed=True):\n+        count = await self.count(deleted_at=None)\n+        if not tag and not count:\n+            tag = \"public\"\n+        model = await self.new()\n+        model['label'] = label\n+        model['description'] = description\n+        model['tag'] = tag \n+        model['created_by_uid'] = created_by_uid\n+        model['is_private'] = is_private\n+        model['is_listed'] = is_listed\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel: {model.errors}.\")\n+    \n+    async def ensure_public_channel(self, created_by_uid):\n+        model = await self.get(is_listed=True,tag=\"public\")\n+        is_moderator = False \n+        if not model:\n+            is_moderator = True \n+            model = await self.create(\"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\")\n+        await self.app.services.channel_member.create(model['uid'], created_by_uid, is_moderator=is_moderator, is_read_only=False, is_muted=False, is_banned=False)\n+        return model\n+    \n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nnew file mode 100644\nindex 0000000..18d4842\n--- /dev/null\n+++ b/src/snek/service/channel_member.py\n@@ -0,0 +1,24 @@\n+from snek.system.service import BaseService \n+\n+class ChannelMemberService(BaseService):\n+\n+    mapper_name = \"channel_member\"\n+\n+    async def create(self, channel_uid, user_uid, is_moderator=False, is_read_only=False, is_muted=False, is_banned=False):\n+        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        if model:\n+            if model.is_banned.value:\n+                return False \n+            return model\n+        model = await self.new()\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        model['label'] = channel['label']\n+        model['channel_uid'] = channel_uid\n+        model['user_uid'] = user_uid\n+        model['is_moderator'] = is_moderator\n+        model['is_read_only'] = is_read_only\n+        model['is_muted'] = is_muted\n+        model['is_banned'] = is_banned\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nnew file mode 100644\nindex 0000000..e1c0007\n--- /dev/null\n+++ b/src/snek/service/channel_message.py\n@@ -0,0 +1,14 @@\n+from snek.system.service import BaseService\n+\n+\n+class ChannelMessageService(BaseService):\n+    mapper_name = \"channel_message\"\n+\n+    async def create(self, channel_uid, user_uid, message):\n+        model = await self.new()\n+        model['channel_uid'] = channel_uid\n+        model['user_uid'] = user_uid\n+        model['message'] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nnew file mode 100644\nindex 0000000..e154323\n--- /dev/null\n+++ b/src/snek/service/notification.py\n@@ -0,0 +1,30 @@\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class NotificationService(BaseService):\n+    mapper_name = \"notification\"\n+\n+    async def create(self, object_uid, object_type, user_uid, message):\n+        model = await self.new()\n+        model['object_uid'] = object_uid\n+        model['object_type'] = object_type\n+        model['user_uid'] = user_uid\n+        model['message'] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create notification: {model.errors}.\")\n+    \n+    async def create_channel_message(self, channel_message_uid):\n+        channel_message = await self.services.channel_message.get(uid=channel_message_uid)\n+        user = await self.services.user.get(uid=channel_message['user_uid'])\n+        async for channel_member in self.services.channel_member.find(channel_uid=channel_message['channel_uid'],is_banned=False,is_muted=False, deleted_at=None):\n+            model = await self.new()\n+            model['object_uid'] = channel_message_uid\n+            model['object_type'] = \"channel_message\"\n+            model['user_uid'] = channel_member['user_uid']\n+            model['message'] = f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            if await self.save(model):\n+                return model\n+            raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex cfcd6b8..11d7489 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -7,10 +7,8 @@ class UserService(BaseService):\n \n     async def validate_login(self, username, password):\n         model = await self.get(username=username)\n-        print(\"FOUND USER!\", model, flush=True)\n         if not model:\n             return False\n-        print(\"AU\", password, model.password.value, flush=True)\n         if not await security.verify(password, model[\"password\"]):\n             return False\n         return True\n@@ -19,9 +17,14 @@ class UserService(BaseService):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n+        model['nick'] = username\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\n         if await self.save(model):\n+            if model:\n+                channel = await self.services.channel.ensure_public_channel(model['uid'])\n+                if not channel:\n+                    raise Exception(\"Failed to create public channel.\")\n             return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 5e275d9..2854e7a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,7 +1,97 @@\n import functools\n+import json\n+import uuid \n+from snek.system import security \n \n cache = functools.cache\n \n+CACHE_MAX_ITEMS_DEFAULT=5000\n+\n+class Cache:\n+    def __init__(self, app,max_items=CACHE_MAX_ITEMS_DEFAULT):\n+        self.app = app\n+        self.cache = {}\n+        self.max_items = max_items\n+        self.lru = []\n+        self.version = ((42+420+1984+1990+10+6+71+3004+7245)^1337)+4\n+\n+    async def get(self, args):\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except:\n+            print(\"Cache miss!\",args,flush=True)\n+            return None\n+        self.lru.insert(0, args)\n+        while(len(self.lru) > self.max_items):\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+        print(\"Cache hit!\",args,flush=True)\n+        return self.cache[args]\n+\n+    def json_default(self, value):\n+        try:\n+            return json.dumps(value.__dict__, default=str)\n+        except:\n+            return str(value)\n+\n+    async def create_cache_key(self, args, kwargs):\n+        return await security.hash(json.dumps({\"args\": args, \"kwargs\": kwargs}, sort_keys=True,default=self.json_default))\n+\n+    async def set(self, args, result):\n+        is_new = not args in self.cache\n+        self.cache[args] = result\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except(ValueError, IndexError):\n+            pass \n+        self.lru.insert(0,args)\n+\n+        while(len(self.lru) > self.max_items):\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+\n+        if is_new:\n+            self.version += 1\n+            print(\"New version:\",self.version,flush=True)\n+\n+    async def delete(self, args):\n+        if args in self.cache:  \n+            try:\n+                self.lru.pop(self.lru.index(args))\n+            except IndexError:\n+                pass \n+            del self.cache[args]\n+\n+    def async_cache(self,func):\n+        @functools.wraps(func)\n+        async def wrapper(*args,**kwargs):\n+            cache_key = await self.create_cache_key(args,kwargs)\n+            cached = await self.get(cache_key)\n+            if cached:\n+                return cached\n+            result = await func(*args,**kwargs)\n+            await self.set(cache_key,result)\n+            return result\n+        return wrapper\n+\n+\n+\n+    def async_delete_cache(self,func):\n+        @functools.wraps(func)\n+        async def wrapper(*args,**kwargs):\n+            cache_key = await self.create_cache_key(args,kwargs)\n+            if cache_key in self.cache:\n+                try:\n+                    self.lru.pop(self.lru.index(cache_key))\n+                except IndexError:\n+                    pass \n+                del self.cache[cache_key]\n+            return await func(*args, **kwargs)\n+            \n+        return wrapper\n+\n \n def async_cache(func):\n     cache = {}\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 489ff90..66946d8 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -54,7 +54,10 @@ class BaseMapper:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield await self.model_class.from_record(mapper=self, record=record)\n+            model = await self.new()\n+            for key, value in record.items():\n+                model[key] = value\n+            yield model\n \n     async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nnew file mode 100644\nindex 0000000..c6d1571\n--- /dev/null\n+++ b/src/snek/system/object.py\n@@ -0,0 +1,15 @@\n+\n+\n+class Object:\n+    \n+    def __init__(self, *args, **kwargs):\n+        for arg in args:\n+            if isinstance(arg,dict):\n+                self.__dict__.update(arg)\n+        self.__dict__.update(kwargs)\n+    \n+    def __getitem__(self, key):\n+        return self.__dict__[key]\n+    \n+    def __setitem__(self, key, value):\n+        self.__dict__[key] = value\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 1f9d601..942c77c 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -7,14 +7,23 @@ class BaseService:\n \n     mapper_name: BaseMapper = None\n \n+    @property \n+    def services(self):\n+        return self.app.services \n+    \n     def __init__(self, app):\n         self.app = app\n+        self.cache = app.cache\n         if self.mapper_name:\n             self.mapper = get_mapper(self.mapper_name, app=self.app)\n         else:\n             self.mapper = None\n \n-    async def exists(self, **kwargs):\n+    async def exists(self,uid=None, **kwargs):\n+        if uid:\n+            if not kwargs and await self.cache.get(uid):\n+                return True \n+            kwargs['uid'] = uid\n         return await self.count(**kwargs) > 0\n \n     async def count(self, **kwargs):\n@@ -23,15 +32,30 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n-    async def get(self, **kwargs):\n-        return await self.mapper.get(**kwargs)\n+    async def get(self,uid=None, **kwargs):\n+        if uid:\n+            if not kwargs:\n+                result = await self.cache.get(uid)\n+                if result:\n+                    return result\n+            kwargs['uid'] = uid \n+            \n+        result = await self.mapper.get(**kwargs)\n+        if result:\n+            await self.cache.set(result['uid'], result)\n+        return result\n \n     async def save(self, model: UserModel):\n-        return await self.mapper.save(model) and True\n+        if  await self.mapper.save(model):\n+            await self.cache.set(model['uid'], model)\n+            return True \n+        errors = await model.errors\n+        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n-        return await self.mapper.find(**kwargs)\n+        async for model in self.mapper.find(**kwargs):\n+            yield model\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 1074615..bec52ed 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -20,8 +20,8 @@ class BaseView(web.View):\n     def db(self):\n         return self.app.db\n \n-    async def json_response(self, data):\n-        return web.json_response(data)\n+    async def json_response(self, data,**kwargs):\n+        return web.json_response(data,**kwargs)\n \n     @property\n     def session(self):\ndiff --git a/src/snek/view/__init__.py b/src/snek/view/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 6566df9..338699a 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,18 +1,25 @@\n-from snek.form.register import RegisterForm\n-from snek.system.view import BaseView\n+from snek.form.login import LoginForm\n+from snek.system.view import BaseFormView, BaseView\n+from aiohttp import web \n \n-\n-class LoginView(BaseView):\n+class LoginView(BaseFormView):\n+    form = LoginForm\n \n     async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n         return await self.render_template(\n             \"login.html\"\n+        )  \n \n-    async def post(self):\n-        form = RegisterForm()\n-        form.set_user_data(await self.request.post())\n-        print(form.is_valid())\n-        return await self.render_template(\n-            \"login.html\", self.request\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\n+\n+    \n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 1186959..8910cf0 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,7 +1,25 @@\n-from snek.system.view import BaseView\n+from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView, BaseView\n+from aiohttp import web\n \n+class RegisterView(BaseFormView):\n+\n+    form = RegisterForm\n \n-class RegisterView(BaseView):\n \n     async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n         return await self.render_template(\"register.html\")\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session[\"uid\"] = result[\"uid\"]\n+        self.request.session[\"username\"] = result[\"username\"]\n+        self.request.session[\"logged_in\"] = True\n+\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex add86a6..5918fa6 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,13 +1,31 @@\n from snek.system.view import BaseView\n-\n+import json\n \n class StatusView(BaseView):\n     async def get(self):\n+        \n+        memberships = []\n+        user = {}\n+        \n+        if self.session.get(\"uid\"):\n+            user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+            if not user:\n+                return await self.json_response({\"error\": \"User not found\"}, status=404)\n+            async for model in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                channel = await self.app.services.channel.get(uid=model['channel_uid'])\n+                memberships.append(dict(name=channel['label'],description=model['description'],user_uid=model['user_uid'],is_moderator=model['is_moderator'],is_read_only=model['is_read_only'],is_muted=model['is_muted'],is_banned=model['is_banned'],channel_uid=model['channel_uid'],uid=model['uid']))\n+            user = dict(\n+                username=user['username'],\n+                email=user['email'],\n+                nick=user['nick'],\n+                uid=user['uid'],\n+                memberships=memberships\n+            )\n+        \n+\n         return await self.json_response(\n             {\n-                \"status\": \"ok\",\n-                \"username\": self.session.get(\"username\"),\n-                \"logged_in\": self.session.get(\"username\") and True or False,\n-                \"uid\": self.session.get(\"uid\"),\n+                \"user\": user,\n+                \"cache\": await self.app.cache.create_cache_key(self.app.cache.cache,None)\n             }\n         )"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor object and cache services for improved performance and maintainability.", "commit": "f25feeeca3502eee94554e7152ca7ca946115053", "diff": "commit f25feeeca3502eee94554e7152ca7ca946115053\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 22:28:33 2025 +0100\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex bc0884a..80be7b5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,5 +1,4 @@\n import pathlib\n-from types import SimpleNamespace\n \n from aiohttp import web\n from aiohttp_session import (\n@@ -21,10 +20,8 @@ from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n-from snek.view.login_form import LoginFormView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n-from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 1f29d73..be22534 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-from types import SimpleNamespace\n \n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n@@ -11,11 +10,13 @@ from snek.system.object import Object\n @functools.cache\n def get_mappers(app=None):\n     return Object(\n-        **{\"user\": UserMapper(app=app),\n-            'channel_member': ChannelMemberMapper(app=app),\n-            'channel': ChannelMapper(app=app),\n-            'channel_message': ChannelMessageMapper(app=app)    \n-    })\n+        **{\n+            \"user\": UserMapper(app=app),\n+            \"channel_member\": ChannelMemberMapper(app=app),\n+            \"channel\": ChannelMapper(app=app),\n+            \"channel_message\": ChannelMessageMapper(app=app),\n+        }\n+    )\n \n \n def get_mapper(name, app=None):\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex 364c1ee..35ccbe9 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -4,4 +4,4 @@ from snek.system.mapper import BaseMapper\n \n class ChannelMessageMapper(BaseMapper):\n     model_class = ChannelMessageModel\n-    table_name = \"channel_message\"\n\\ No newline at end of file\n+    table_name = \"channel_message\"\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex ccb5289..c87d39c 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -2,7 +2,8 @@ import functools\n \n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n+\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.user import UserModel\n from snek.system.object import Object\n@@ -10,10 +11,14 @@ from snek.system.object import Object\n \n @functools.cache\n def get_models():\n-    return Object(**{\"user\": UserModel,\n+    return Object(\n+        **{\n+            \"user\": UserModel,\n             \"channel_member\": ChannelMemberModel,\n             \"channel\": ChannelModel,\n-            \"channel_message\": ChannelMessageModel})\n+            \"channel_message\": ChannelMessageModel,\n+        }\n+    )\n \n \n def get_model(name):\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 50b1181..d664087 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,11 +1,11 @@\n from snek.system.model import BaseModel, ModelField\n \n-class ChannelModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True,kind=str)\n-    description = ModelField(name=\"description\", required=False,kind=str)\n-    tag = ModelField(name=\"tag\", required=False,kind=str)\n-    created_by_uid = ModelField(name=\"created_by_uid\", required=True,kind=str)\n-    is_private = ModelField(name=\"is_private\", required=True,kind=bool,value=False)\n-    is_listed = ModelField(name=\"is_listed\", required=True,kind=bool,value=True)\n-    index = ModelField(name=\"index\", required=True,kind=int,value=1000) \n \n+class ChannelModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    description = ModelField(name=\"description\", required=False, kind=str)\n+    tag = ModelField(name=\"tag\", required=False, kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n+    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 48131e4..d199498 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,10 +1,15 @@\n from snek.system.model import BaseModel, ModelField\n \n+\n class ChannelMemberModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True,kind=str)\n-    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n-    is_moderator = ModelField(name=\"is_moderator\", required=True,kind=bool,value=False)\n-    is_read_only = ModelField(name=\"is_read_only\", required=True,kind=bool,value=False)\n-    is_muted = ModelField(name=\"is_muted\", required=True,kind=bool,value=False)\n-    is_banned = ModelField(name=\"is_banned\", required=True,kind=bool,value=False)\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    is_moderator = ModelField(\n+        name=\"is_moderator\", required=True, kind=bool, value=False\n+    )\n+    is_read_only = ModelField(\n+        name=\"is_read_only\", required=True, kind=bool, value=False\n+    )\n+    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 9e96307..0fab568 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,9 +1,7 @@\n-\n-\n from snek.system.model import BaseModel, ModelField\n \n \n class ChannelMessageModel(BaseModel):\n-    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n-    message = ModelField(name=\"message\", required=True,kind=str)\n\\ No newline at end of file\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    message = ModelField(name=\"message\", required=True, kind=str)\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex 0a5c294..6a12328 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,6 +1,3 @@\n-\n-\n-\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -9,4 +6,4 @@ class NotificationModel(BaseModel):\n     object_type = ModelField(name=\"object_type\", required=True)\n     message = ModelField(name=\"message\", required=True)\n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    read_at = ModelField(name=\"is_read\", required=True)\n\\ No newline at end of file\n+    read_at = ModelField(name=\"is_read\", required=True)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 8457917..c81a456 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,21 +1,20 @@\n import functools\n \n from snek.service.channel import ChannelService\n-from snek.service.user import UserService\n from snek.service.channel_member import ChannelMemberService\n-from types import SimpleNamespace\n-\n+from snek.service.user import UserService\n from snek.system.object import Object\n \n+\n @functools.cache\n def get_services(app):\n     return Object(\n         **{\n             \"user\": UserService(app=app),\n             \"channel_member\": ChannelMemberService(app=app),\n-            'channel': ChannelService(app=app)\n+            \"channel\": ChannelService(app=app),\n         }\n-)\n+    )\n \n \n def get_service(name, app=None):\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 9290baf..ee23c2d 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,31 +1,48 @@\n from snek.system.service import BaseService\n \n+\n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n-    async def create(self, label, created_by_uid,  description=None, tag=None, is_private=False, is_listed=True):\n+    async def create(\n+        self,\n+        label,\n+        created_by_uid,\n+        description=None,\n+        tag=None,\n+        is_private=False,\n+        is_listed=True,\n+    ):\n         count = await self.count(deleted_at=None)\n         if not tag and not count:\n             tag = \"public\"\n         model = await self.new()\n-        model['label'] = label\n-        model['description'] = description\n-        model['tag'] = tag \n-        model['created_by_uid'] = created_by_uid\n-        model['is_private'] = is_private\n-        model['is_listed'] = is_listed\n+        model[\"label\"] = label\n+        model[\"description\"] = description\n+        model[\"tag\"] = tag\n+        model[\"created_by_uid\"] = created_by_uid\n+        model[\"is_private\"] = is_private\n+        model[\"is_listed\"] = is_listed\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n-    \n+\n     async def ensure_public_channel(self, created_by_uid):\n-        model = await self.get(is_listed=True,tag=\"public\")\n-        is_moderator = False \n+        model = await self.get(is_listed=True, tag=\"public\")\n+        is_moderator = False\n         if not model:\n-            is_moderator = True \n-            model = await self.create(\"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\")\n-        await self.app.services.channel_member.create(model['uid'], created_by_uid, is_moderator=is_moderator, is_read_only=False, is_muted=False, is_banned=False)\n+            is_moderator = True\n+            model = await self.create(\n+                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n+            )\n+        await self.app.services.channel_member.create(\n+            model[\"uid\"],\n+            created_by_uid,\n+            is_moderator=is_moderator,\n+            is_read_only=False,\n+            is_muted=False,\n+            is_banned=False,\n+        )\n         return model\n-    \n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 18d4842..adbcded 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,24 +1,33 @@\n-from snek.system.service import BaseService \n+from snek.system.service import BaseService\n+\n \n class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n-    async def create(self, channel_uid, user_uid, is_moderator=False, is_read_only=False, is_muted=False, is_banned=False):\n+    async def create(\n+        self,\n+        channel_uid,\n+        user_uid,\n+        is_moderator=False,\n+        is_read_only=False,\n+        is_muted=False,\n+        is_banned=False,\n+    ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n             if model.is_banned.value:\n-                return False \n+                return False\n             return model\n         model = await self.new()\n         channel = await self.services.channel.get(uid=channel_uid)\n-        model['label'] = channel['label']\n-        model['channel_uid'] = channel_uid\n-        model['user_uid'] = user_uid\n-        model['is_moderator'] = is_moderator\n-        model['is_read_only'] = is_read_only\n-        model['is_muted'] = is_muted\n-        model['is_banned'] = is_banned\n+        model[\"label\"] = channel[\"label\"]\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"is_moderator\"] = is_moderator\n+        model[\"is_read_only\"] = is_read_only\n+        model[\"is_muted\"] = is_muted\n+        model[\"is_banned\"] = is_banned\n         if await self.save(model):\n             return model\n-        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n\\ No newline at end of file\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex e1c0007..d86de26 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -6,9 +6,9 @@ class ChannelMessageService(BaseService):\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n-        model['channel_uid'] = channel_uid\n-        model['user_uid'] = user_uid\n-        model['message'] = message\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n         if await self.save(model):\n             return model\n-        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n\\ No newline at end of file\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex e154323..3815c60 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,5 +1,3 @@\n-\n-\n from snek.system.service import BaseService\n \n \n@@ -8,23 +6,32 @@ class NotificationService(BaseService):\n \n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n-        model['object_uid'] = object_uid\n-        model['object_type'] = object_type\n-        model['user_uid'] = user_uid\n-        model['message'] = message\n+        model[\"object_uid\"] = object_uid\n+        model[\"object_type\"] = object_type\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")\n-    \n+\n     async def create_channel_message(self, channel_message_uid):\n-        channel_message = await self.services.channel_message.get(uid=channel_message_uid)\n-        user = await self.services.user.get(uid=channel_message['user_uid'])\n-        async for channel_member in self.services.channel_member.find(channel_uid=channel_message['channel_uid'],is_banned=False,is_muted=False, deleted_at=None):\n+        channel_message = await self.services.channel_message.get(\n+            uid=channel_message_uid\n+        )\n+        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n             model = await self.new()\n-            model['object_uid'] = channel_message_uid\n-            model['object_type'] = \"channel_message\"\n-            model['user_uid'] = channel_member['user_uid']\n-            model['message'] = f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n             if await self.save(model):\n                 return model\n             raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 11d7489..60825a5 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -17,13 +17,15 @@ class UserService(BaseService):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n-        model['nick'] = username\n+        model[\"nick\"] = username\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\n         if await self.save(model):\n             if model:\n-                channel = await self.services.channel.ensure_public_channel(model['uid'])\n+                channel = await self.services.channel.ensure_public_channel(\n+                    model[\"uid\"]\n+                )\n                 if not channel:\n                     raise Exception(\"Failed to create public channel.\")\n             return model\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 2854e7a..86f5557 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,35 +1,36 @@\n import functools\n import json\n-import uuid \n-from snek.system import security \n+\n+from snek.system import security\n \n cache = functools.cache\n \n-CACHE_MAX_ITEMS_DEFAULT=5000\n+CACHE_MAX_ITEMS_DEFAULT = 5000\n+\n \n class Cache:\n-    def __init__(self, app,max_items=CACHE_MAX_ITEMS_DEFAULT):\n+    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n         self.app = app\n         self.cache = {}\n         self.max_items = max_items\n         self.lru = []\n-        self.version = ((42+420+1984+1990+10+6+71+3004+7245)^1337)+4\n+        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n-            print(\"Cache miss!\",args,flush=True)\n+            print(\"Cache miss!\", args, flush=True)\n             return None\n         self.lru.insert(0, args)\n-        while(len(self.lru) > self.max_items):\n+        while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n-        print(\"Cache hit!\",args,flush=True)\n+        print(\"Cache hit!\", args, flush=True)\n         return self.cache[args]\n \n     def json_default(self, value):\n         try:\n             return json.dumps(value.__dict__, default=str)\n@@ -37,59 +38,64 @@ class Cache:\n             return str(value)\n \n     async def create_cache_key(self, args, kwargs):\n-        return await security.hash(json.dumps({\"args\": args, \"kwargs\": kwargs}, sort_keys=True,default=self.json_default))\n+        return await security.hash(\n+            json.dumps(\n+                {\"args\": args, \"kwargs\": kwargs},\n+                sort_keys=True,\n+                default=self.json_default,\n+            )\n+        )\n \n     async def set(self, args, result):\n-        is_new = not args in self.cache\n+        is_new = args not in self.cache\n         self.cache[args] = result\n         try:\n             self.lru.pop(self.lru.index(args))\n-        except(ValueError, IndexError):\n-            pass \n-        self.lru.insert(0,args)\n+        except (ValueError, IndexError):\n+            pass\n+        self.lru.insert(0, args)\n \n-        while(len(self.lru) > self.max_items):\n+        while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n \n         if is_new:\n             self.version += 1\n-            print(\"New version:\",self.version,flush=True)\n+            print(\"New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n-        if args in self.cache:  \n+        if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\n             except IndexError:\n-                pass \n+                pass\n             del self.cache[args]\n \n-    def async_cache(self,func):\n+    def async_cache(self, func):\n         @functools.wraps(func)\n-        async def wrapper(*args,**kwargs):\n-            cache_key = await self.create_cache_key(args,kwargs)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n             cached = await self.get(cache_key)\n             if cached:\n                 return cached\n-            result = await func(*args,**kwargs)\n-            await self.set(cache_key,result)\n+            result = await func(*args, **kwargs)\n+            await self.set(cache_key, result)\n             return result\n-        return wrapper\n-\n \n+        return wrapper\n \n-    def async_delete_cache(self,func):\n+    def async_delete_cache(self, func):\n         @functools.wraps(func)\n-        async def wrapper(*args,**kwargs):\n-            cache_key = await self.create_cache_key(args,kwargs)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n             if cache_key in self.cache:\n                 try:\n                     self.lru.pop(self.lru.index(cache_key))\n                 except IndexError:\n-                    pass \n+                    pass\n                 del self.cache[cache_key]\n             return await func(*args, **kwargs)\n-            \n+\n         return wrapper\n \n \ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex c6d1571..f91ec42 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,15 +1,13 @@\n-\n-\n class Object:\n-    \n+\n     def __init__(self, *args, **kwargs):\n         for arg in args:\n-            if isinstance(arg,dict):\n+            if isinstance(arg, dict):\n                 self.__dict__.update(arg)\n         self.__dict__.update(kwargs)\n-    \n+\n     def __getitem__(self, key):\n         return self.__dict__[key]\n-    \n+\n     def __setitem__(self, key, value):\n-        self.__dict__[key] = value\n\\ No newline at end of file\n+        self.__dict__[key] = value\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 942c77c..60d27bb 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -7,10 +7,10 @@ class BaseService:\n \n     mapper_name: BaseMapper = None\n \n-    @property \n+    @property\n     def services(self):\n-        return self.app.services \n-    \n+        return self.app.services\n+\n     def __init__(self, app):\n         self.app = app\n         self.cache = app.cache\n@@ -19,11 +19,11 @@ class BaseService:\n         else:\n             self.mapper = None\n \n-    async def exists(self,uid=None, **kwargs):\n+    async def exists(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs and await self.cache.get(uid):\n-                return True \n-            kwargs['uid'] = uid\n+                return True\n+            kwargs[\"uid\"] = uid\n         return await self.count(**kwargs) > 0\n \n     async def count(self, **kwargs):\n@@ -32,24 +32,24 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n-    async def get(self,uid=None, **kwargs):\n+    async def get(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs:\n                 result = await self.cache.get(uid)\n                 if result:\n                     return result\n-            kwargs['uid'] = uid \n-            \n+            kwargs[\"uid\"] = uid\n+\n         result = await self.mapper.get(**kwargs)\n         if result:\n-            await self.cache.set(result['uid'], result)\n+            await self.cache.set(result[\"uid\"], result)\n         return result\n \n     async def save(self, model: UserModel):\n-        if  await self.mapper.save(model):\n-            await self.cache.set(model['uid'], model)\n-            return True \n+        if await self.mapper.save(model):\n+            await self.cache.set(model[\"uid\"], model)\n+            return True\n         errors = await model.errors\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex bec52ed..8b775e0 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -20,8 +20,8 @@ class BaseView(web.View):\n     def db(self):\n         return self.app.db\n \n-    async def json_response(self, data,**kwargs):\n-        return web.json_response(data,**kwargs)\n+    async def json_response(self, data, **kwargs):\n+        return web.json_response(data, **kwargs)\n \n     @property\n     def session(self):\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 338699a..396d11e 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,6 +1,8 @@\n+from aiohttp import web\n+\n from snek.form.login import LoginForm\n-from snek.system.view import BaseFormView, BaseView\n-from aiohttp import web \n+from snek.system.view import BaseFormView\n+\n \n class LoginView(BaseFormView):\n     form = LoginForm\n@@ -10,9 +12,7 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\n-            \"login.html\"\n-        )  \n+        return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n         if await form.is_valid:\n@@ -21,5 +21,3 @@ class LoginView(BaseFormView):\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}\n-\n-    \n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 8910cf0..eb1c8d8 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,12 +1,13 @@\n-from snek.form.register import RegisterForm\n-from snek.system.view import BaseFormView, BaseView\n from aiohttp import web\n \n+from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView\n+\n+\n class RegisterView(BaseFormView):\n \n     form = RegisterForm\n \n-\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 5918fa6..a307dee 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,31 +1,46 @@\n from snek.system.view import BaseView\n-import json\n+\n \n class StatusView(BaseView):\n     async def get(self):\n-        \n+\n         memberships = []\n         user = {}\n-        \n+\n         if self.session.get(\"uid\"):\n             user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n-            async for model in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                channel = await self.app.services.channel.get(uid=model['channel_uid'])\n-                memberships.append(dict(name=channel['label'],description=model['description'],user_uid=model['user_uid'],is_moderator=model['is_moderator'],is_read_only=model['is_read_only'],is_muted=model['is_muted'],is_banned=model['is_banned'],channel_uid=model['channel_uid'],uid=model['uid']))\n-            user = dict(\n-                username=user['username'],\n-                email=user['email'],\n-                nick=user['nick'],\n-                uid=user['uid'],\n-                memberships=memberships\n-            )\n-        \n+            async for model in self.app.services.channel_member.find(\n+                user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n+                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n+                memberships.append(\n+                    {\n+                        \"name\": channel[\"label\"],\n+                        \"description\": model[\"description\"],\n+                        \"user_uid\": model[\"user_uid\"],\n+                        \"is_moderator\": model[\"is_moderator\"],\n+                        \"is_read_only\": model[\"is_read_only\"],\n+                        \"is_muted\": model[\"is_muted\"],\n+                        \"is_banned\": model[\"is_banned\"],\n+                        \"channel_uid\": model[\"channel_uid\"],\n+                        \"uid\": model[\"uid\"],\n+                    }\n+                )\n+            user = {\n+                \"username\": user[\"username\"],\n+                \"email\": user[\"email\"],\n+                \"nick\": user[\"nick\"],\n+                \"uid\": user[\"uid\"],\n+                \"memberships\": memberships,\n+            }\n \n         return await self.json_response(\n             {\n                 \"user\": user,\n-                \"cache\": await self.app.cache.create_cache_key(self.app.cache.cache,None)\n+                \"cache\": await self.app.cache.create_cache_key(\n+                    self.app.cache.cache, None\n+                ),\n             }\n         )"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and WebSocket support for real-time communication.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df", "diff": "commit 488afdcc747df9593273f652b17b5fe8db07b1df\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:48:58 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 80be7b5..f242090 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -22,6 +22,7 @@ from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n+from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \n@@ -77,7 +78,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-\n+        self.router.add_get(\"/rpc.ws\",RPCView)\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex e69de29..05e62d8 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -0,0 +1,9 @@\n+\n+\n+from snek.model.notification import NotificationModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class NotificationMapper(BaseMapper):\n+    table_name = \"notification\"\n+    model_class = NotificationModel\n\\ No newline at end of file\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex c81a456..db4a00f 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -2,6 +2,9 @@ import functools\n \n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n+from snek.service.channel_message import ChannelMessageService\n+from snek.service.chat import ChatService\n+from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.system.object import Object\n \n@@ -13,6 +16,9 @@ def get_services(app):\n             \"user\": UserService(app=app),\n             \"channel_member\": ChannelMemberService(app=app),\n             \"channel\": ChannelService(app=app),\n+            \"channel_message\": ChannelMessageService(app=app),\n+            \"chat\": ChatService(app=app),\n+            \"socket\": SocketService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex adbcded..a1eec8c 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,6 +28,7 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n+        print(model.record,flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 133cbcd..2be1c09 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,6 +1,6 @@\n \n \n-class Message {\n     uid = null \n     author = null\n     avatar = null \n@@ -38,7 +38,7 @@ class Message {\n         }\n         return result \n     }\n-}\n \n \n class Messages {\n@@ -204,17 +204,153 @@ class Chat extends EventHandler {\n \n }\n \n+class Socket extends EventHandler {\n+    ws = null \n+    isConnected = null\n+    isConnecting = null\n+    connectPromises = []\n+    constructor() {\n+        super()\n+        this.ensureConnection() \n+    }\n+    _camelToSnake(str) {\n+        return str\n+          .replace(/([a-z])([A-Z])/g, '$1_$2') \n+          .toLowerCase(); \n+    }\n+    get client() {\n+        const me = this\n+        const proxy = new Proxy(\n+            {},\n+            {\n+            get(target, prop) {\n+                return (...args) => {\n+                  let functionName = me._camelToSnake(prop)\n+                  return me.call(functionName, ...args);\n+                };\n+              },\n+            }\n+          );\n+        return proxy\n+    }\n+    ensureConnection(){\n+        return this.connect()\n+    }\n+    generateUniqueId() {\n+        return 'id-' + Math.random().toString(36).substr(2, 9); \n+    }\n+    connect(){\n+        const me  = this \n+        if(!this.isConnected && !this.isConnecting){\n+            this.isConnecting = true \n+        }else if (this.isConnecting){\n+            return new Promise((resolve,reject)=>{\n+                me.connectPromises.push(resolve)\n+            }) \n+        }else if(this.isConnected){\n+            return new Promise((resolve,reject)=>{\n+                resolve(me)\n+            })\n+        }\n+        return new Promise((resolve,reject)=>{\n+            me.connectPromises.push(resolve)\n+            ws.onopen = (event) => {\n+                me.ws = ws \n+                me.isConnected = true \n+                me.isConnecting = false\n+                ws.onmessage = (event) => {\n+                    me.onData(JSON.parse(event.data))\n+                }\n+                ws.onclose = (event) =>{\n+                    me.onClose()\n+                \n+                }\n+                me.connectPromises.forEach(resolve=>{\n+                  resolve(me)  \n+                })\n+            }\n+        })\n+    }\n+    onData(data){\n+        console.debug(\"Data received\",data)\n+        if(data.callId){\n+            this.emit(data.callId, data.data)\n+        }\n+        if(data.channel_uid){\n+            this.emit(data.channel_uid,data.data)\n+            this.emit(\"channel-message\",data)\n+        }\n+        \n+    }\n+    async sendJson(data){\n+        return await this.connect().then((api)=>{\n+            api.ws.send(JSON.stringify(data))\n+        })\n+    }\n+    async call(method,...args){\n+        const call= {\n+            callId: this.generateUniqueId(),\n+            method: method,\n+            args: args\n+        }\n+        \n+        const me = this \n+        return new Promise(async(resolve,reject)=>{\n+                me.addEventListener(call.callId,(data)=>{\n+                    resolve(data)\n+                })\n+                await me.sendJson(call)\n+            \n+\n+            })\n+    }\n+    onClose(){\n+        console.info(\"Connection lost. Reconnecting.\")\n+        this.isConnected = false \n+        this.isConnecting = false\n+        this.ensureConnection().then(()=>{\n+            console.info(\"Reconnected.\")\n+        })\n+    }\n+\n+}\n \n-class App {\n+class App extends EventHandler {\n     rooms = []\n+    rest = rest \n+    ws = null \n+    rpc = null \n     constructor() {\n+        super()\n         this.rooms.push(new Room(\"General\"))\n-\n-\n+        this.ws = new Socket()\n+        this.rpc = this.ws.client \n+        const me = this \n+        this.ws.addEventListener(\"channel-message\", (data) => {\n+            console.debug(\"App channel message!\",data)\n+            me.emit(data.channel_uid,data)\n+        }) \n     }\n-    async post(url, data){\n-\n+    async benchMark(times) {\n+        if(!times)\n+            times = 100\n+        let promises = []\n+        const me = this \n+        for(let i = 0; i < times; i++){\n+            promises.push(this.rpc.getChannels().then(channels=>{\n+                channels.forEach(channel=>{\n+                    me.rpc.sendMessage(channel.uid,`Haha ${i}`).then(data=>{\n+                        console.info(data)\n+                    })\n+                })\n+            }))\n+            \n+        }\n+        return await Promise.all(promises)\n     }\n \n \n-}\n\\ No newline at end of file\n+}\n+\n+const app = new App()\n\\ No newline at end of file\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 263f1eb..b674b8d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -109,6 +109,12 @@ main {\n }\n \n+.message-list-manager {\n+  flex: 1;\n+  overflow-y: auto;\n+}\n+\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8b775e0..cd9112d 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -19,6 +19,10 @@ class BaseView(web.View):\n     @property\n     def db(self):\n         return self.app.db\n+    \n+    @property\n+    def services(self):\n+        return self.app.services\n \n     async def json_response(self, data, **kwargs):\n         return web.json_response(data, **kwargs)\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex cb8fc5d..36a012c 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -5,6 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n   <script src=\"/app.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n   <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ae0bb06..d56b4f2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,6 +5,9 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <script src=\"/app.js\"></script>\n+  <script src=\"/models.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n+  <script src=\"/message-list-manager.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n@@ -31,31 +34,23 @@\n       <div class=\"chat-header\">\n         <h2>General</h2>\n       </div>\n-      <div class=\"chat-messages\">\n-        <div class=\"message\">\n-          <div class=\"avatar\">A</div>\n-          <div class=\"message-content\">\n-            <div class=\"author\">Alice</div>\n-            <div class=\"text\">Hello, everyone!</div>\n-            <div class=\"time\">10:45 AM</div>\n-          </div>\n-        </div>\n-        <html-frame class=\"html-frame\" url=\"/register\"></html-frame>\n-        <div class=\"message\">\n-          <div class=\"avatar\">B</div>\n-          <div class=\"message-content\">\n-            <div class=\"author\">Bob</div>\n-            <div class=\"text\">Hi Alice! How are you?</div>\n-            <div class=\"time\">10:46 AM</div>\n-          </div>\n-        </div>\n-      </div>\n+      <message-list-manager class=\"message-list-manager\"></message-list-manager>\n       <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <button>Send</button>\n       </div>\n     </section>\n   </main>\n-  \n+  <script>\n+    document.addEventListener(\"DOMContentLoaded\",()=>{\n+      setTimeout(()=>{\n+      app.benchMark(3).then(result=>{\n+        console.info(\"Benchmarked\")\n+      })\n+    },1000)\n+      \n+    })\n+\n+  </script>\n </body>\n </html>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-26", "line": "fix: Minor formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58", "diff": "commit 4c601e8333b3a462c63ab6e02b73b9f5306b4a58\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:49:37 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f242090..7546ae0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -78,7 +78,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-        self.router.add_get(\"/rpc.ws\",RPCView)\n+        self.router.add_get(\"/rpc.ws\", RPCView)\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex 05e62d8..9bd74b5 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,9 +1,7 @@\n-\n-\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n \n \n class NotificationMapper(BaseMapper):\n     table_name = \"notification\"\n-    model_class = NotificationModel\n\\ No newline at end of file\n+    model_class = NotificationModel\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex a1eec8c..6b9ce9e 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,7 +28,7 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n-        print(model.record,flush=True)\n+        print(model.record, flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex cd9112d..8c7537e 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -19,7 +19,7 @@ class BaseView(web.View):\n     @property\n     def db(self):\n         return self.app.db\n-    \n+\n     @property\n     def services(self):\n         return self.app.services"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f", "diff": "commit 4ae846cf8b4f1158ac47ce2825d37e03e9b6677f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:51:51 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nnew file mode 100644\nindex 0000000..3dd67e3\n--- /dev/null\n+++ b/src/snek/service/chat.py\n@@ -0,0 +1,43 @@\n+\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class ChatService(BaseService):\n+\n+    async def send(self,user_uid, channel_uid, message):\n+        channel_message = await self.services.channel_message.create(\n+            user_uid, \n+            channel_uid, \n+            message\n+        )\n+        channel_message_uid = channel_message[\"uid\"]\n+        \n+        user = await self.services.user.get(uid=user_uid)\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            model = await self.new()\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n+            if not await self.services.channel_member.save(model):\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\n+            \n+        sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n+            message=message,\n+            user_uid=user_uid,\n+            channel_uid=channel_uid,\n+            created_at=channel_message[\"created_at\"], \n+            updated_at=None,\n+            uid=channel_message['uid'],\n+            user_nick=user['nick']\n+        ))\n+        return sent_to_count\n\\ No newline at end of file\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nnew file mode 100644\nindex 0000000..eb4695c\n--- /dev/null\n+++ b/src/snek/service/socket.py\n@@ -0,0 +1,33 @@\n+\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class SocketService(BaseService):\n+\n+    def __init__(self, app):\n+        super().__init__(app)\n+        self.sockets = set()\n+        self.subscriptions = {}\n+\n+    async def add(self, ws):\n+        self.sockets.add(ws)\n+\n+    async def subscribe(self, ws, channel_uid):\n+        if not channel_uid in self.subscriptions:\n+            self.subscriptions[channel_uid] = set()\n+        self.subscriptions[channel_uid].add(ws)\n+\n+    async def broadcast(self, channel_uid, message):\n+        print(\"BROADCAT!\",message)\n+        count = 0\n+        for ws in self.subscriptions.get(channel_uid,[]):\n+            await ws.send_json(message)\n+            count += 1\n+        return count\n+    async def delete(self, ws):\n+        try:\n+            self.sockets.remove(ws) \n+        except IndexError:\n+            pass \n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nnew file mode 100644\nindex 0000000..69a3f87\n--- /dev/null\n+++ b/src/snek/static/message-list-manager.js\n@@ -0,0 +1,23 @@\n+\n+\n+class MessageListManagerElement extends HTMLElement {\n+    constructor() {\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement(\"div\")\n+        this.shadowRoot.appendChild(this.container)\n+    }\n+\n+    async connectedCallback() {\n+        let channels = await app.rpc.getChannels()\n+        const me = this \n+        channels.forEach(channel=>{\n+            const messageList = document.createElement(\"message-list\")\n+            messageList.setAttribute(\"channel\",channel.uid)\n+            me.container.appendChild(messageList)\n+        })\n+    }\n+\n+}\n+\n+customElements.define(\"message-list-manager\",MessageListManagerElement)\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nnew file mode 100644\nindex 0000000..c1d7b3d\n--- /dev/null\n+++ b/src/snek/static/message-list.js\n@@ -0,0 +1,86 @@\n+\n+\n+class MessageListElement extends HTMLElement {\n+    \n+    static get observedAttributes() {\n+        return [\"messages\"];\n+    }\n+    messages = []\n+    room = null\n+    url = null\n+    container = null\n+    constructor() {\n+        super()\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div')\n+        this.shadowRoot.appendChild(this.component )\n+    }\n+    createElement(message){\n+        const element = document.createElement(\"div\")\n+\n+        element.classList.add(\"message\")\n+        const avatar = document.createElement(\"div\")\n+        avatar.classList.add(\"avatar\")\n+        avatar.innerText = message.user_nick[0]\n+        const messageContent = document.createElement(\"div\")\n+        messageContent.classList.add(\"message-content\")\n+        const author = document.createElement(\"div\")\n+        author.classList.add(\"author\")\n+        author.textContent = message.user_nick\n+        const text = document.createElement(\"div\")\n+        text.classList.add(\"text\")\n+        text.textContent = message.message\n+        const time = document.createElement(\"div\")\n+        time.classList.add(\"time\")\n+        time.textContent = message.created_at\n+        messageContent.appendChild(author)\n+        messageContent.appendChild(text)\n+        messageContent.appendChild(time)\n+        element.appendChild(avatar)\n+        element.appendChild(messageContent)\n+\n+\n+\n+        message.element = element \n+       \n+        return element\n+    }\n+    addMessage(message){\n+        \n+        const obj = new models.Message(\n+            message.uid,\n+            message.channel_uid,\n+            message.user_uid,\n+            message.user_nick,\n+            message.message,\n+            message.created_at,\n+            message.updated_at\n+        )\n+        const element = this.createElement(obj)\n+        this.messages.push(obj)\n+        this.container.appendChild(element)\n+        return obj\n+    }\n+    connectedCallback() {\n+        const link = document.createElement('link')\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement('div')\n+        this.container.classList.add(\"chat-messages\")\n+        this.component.appendChild(this.container)\n+        \n+        this.messages = []\n+        this.channel_uid = this.getAttribute(\"channel\")\n+        const me = this\n+        app.addEventListener(this.channel_uid, (data) => {\n+            console.info(\"WIIIIIIIIIIIIIIIIIIIIIIII\")\n+            me.addMessage(data)\n+        })\n+        this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n+        \n+        \n+    }\n+}\n+\n+customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nnew file mode 100644\nindex 0000000..6589279\n--- /dev/null\n+++ b/src/snek/static/models.js\n@@ -0,0 +1,22 @@\n+class MessageModel {\n+    message = null \n+    user_uid = null \n+    channel_uid = null \n+    created_at = null \n+    updated_at = null  \n+    element = null \n+    constructor(uid, channel_uid,user_uid,user_nick, message,created_at, updated_at){\n+        this.uid = uid \n+        this.message = message \n+        this.user_uid = user_uid \n+        this.user_nick = user_nick\n+        this.channel_uid = channel_uid  \n+        this.created_at = created_at\n+        this.updated_at = updated_at\n+    }       \n+}\n+\n+const models = {\n+    Message: MessageModel\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nnew file mode 100644\nindex 0000000..b3611ec\n--- /dev/null\n+++ b/src/snek/view/rpc.py\n@@ -0,0 +1,75 @@\n+from aiohttp import web  \n+from snek.system.view import BaseView\n+\n+\n+class RPCView(BaseView):\n+\n+    login_required = True\n+\n+    class RPCApi:\n+        def __init__(self,view, ws):\n+            self.view = view \n+            self.app = self.view.app\n+            self.services = self.app.services\n+            self.user_uid = self.view.session.get(\"uid\")\n+            self.ws = ws \n+            \n+       \n+        async def get_channels(self):\n+            channels = []\n+            async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n+                channels.append(dict(\n+                    name=subscription[\"label\"],\n+                    uid=subscription[\"channel_uid\"],\n+                    is_moderator=subscription[\"is_moderator\"],\n+                    is_read_only=subscription[\"is_read_only\"]\n+                ))\n+            return channels\n+\n+        async def send_message(self, room, message):\n+            await self.services.chat.send(self.user_uid,room,message)\n+            return True \n+        \n+\n+        async def echo(self,*args):\n+            return args\n+\n+\n+\n+\n+\n+        async def __call__(self, data):\n+            call_id = data.get(\"callId\")\n+            method_name = data.get(\"method\")\n+            args = data.get(\"args\")\n+            if hasattr(super(),method_name) or not hasattr(self,method_name):\n+                return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+\n+            method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+            result = await method(*args)\n+            await self.ws.send_json({\"callId\":call_id,\"data\":result})\n+\n+\n+    async def call_ping(self,callId,*args):\n+        return {\"pong\": args}\n+\n+\n+    async def get(self):\n+\n+        \n+        ws = web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        await self.services.socket.add(ws)\n+        async for subscription in self.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+            await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n+            print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n+        rpc = RPCView.RPCApi(self,ws)\n+        async for msg in ws:\n+            if msg.type == web.WSMsgType.TEXT:\n+                await rpc(msg.json())\n+            elif msg.type == web.WSMsgType.ERROR:\n+                print(f\"WebSocket exception {ws.exception()}\")\n+\n+        await self.services.socket.delete(ws)\n+        print(\"WebSocket connection closed\")\n+        return ws\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added WebSocket connection URL and improved connection handling", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4", "diff": "commit fb7cb35921b73fd22a4ef045fe23b8dab87a7af4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:54:29 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 2be1c09..f6490e8 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -208,9 +208,11 @@ class Socket extends EventHandler {\n     ws = null \n     isConnected = null\n     isConnecting = null\n+    url = null\n     connectPromises = []\n     constructor() {\n         super()\n         this.ensureConnection() \n     }\n     _camelToSnake(str) {\n@@ -254,7 +256,8 @@ class Socket extends EventHandler {\n         }\n         return new Promise((resolve,reject)=>{\n             me.connectPromises.push(resolve)\n+            \n+            const ws = new WebSocket(this.url)\n             ws.onopen = (event) => {\n                 me.ws = ws \n                 me.isConnected = true"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Implement chat window component and websocket error handling\n\nfix: Add query methods to mapper and service classes", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44", "diff": "commit 36c69eb8bb35068faebd396af1375fe5927eec44\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 00:56:06 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex eb4695c..55909ce 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -23,7 +23,11 @@ class SocketService(BaseService):\n         print(\"BROADCAT!\",message)\n         count = 0\n         for ws in self.subscriptions.get(channel_uid,[]):\n-            await ws.send_json(message)\n+            try:\n+                await ws.send_json(message)\n+            except Exception as ex:\n+                print(ex)\n+                continue \n             count += 1\n         return count\n     async def delete(self, ws):\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f6490e8..1e98ec5 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -212,7 +212,7 @@ class Socket extends EventHandler {\n     connectPromises = []\n     constructor() {\n         super()\n         this.ensureConnection() \n     }\n     _camelToSnake(str) {\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 66946d8..9722534 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -59,6 +59,10 @@ class BaseMapper:\n                 model[key] = value\n             yield model\n \n+    async def query(self, sql, *args):\n+        for record in self.db.query(sql, *args):\n+            yield dict(record)\n+\n     async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 60d27bb..51e4b9f 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -32,6 +32,10 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n+    async def query(self, sql, *args):\n+        for record in self.app.db.query(sql, *args):\n+            yield record\n+\n     async def get(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs:\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d56b4f2..f6635d3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -8,6 +8,7 @@\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>\n   <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n@@ -30,12 +31,10 @@\n       </ul>\n     </aside>\n-    <section class=\"chat-area\">\n-      <div class=\"chat-header\">\n-        <h2>General</h2>\n-      </div>\n-      <message-list-manager class=\"message-list-manager\"></message-list-manager>\n-      <div class=\"chat-input\">\n+    <section>\n+    <chat-window class=\"chat-area\"></chat-window>\n+  \n+    <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <button>Send</button>\n       </div>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b3611ec..6936780 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -15,6 +15,19 @@ class RPCView(BaseView):\n             self.ws = ws \n             \n        \n+        async def get_messages(self, channel_uid,offset=0):\n+            messages = []\n+            async for message in self.services.channel_message.query(\"SELECT channel_uid, user_uid, message, created_at FROM channel_message WHERE channel_uid = :channel_uid ORDER BY created_at DESC LIMIT 30 OFFSET :offset\",{\"channel_uid\":channel_uid,\"offset\":int(offset)}):\n+                messages.append(dict(\n+                    uid=message[\"uid\"],\n+                    user_uid=message[\"user_uid\"],\n+                    channel_uid=message[\"channel_uid\"],\n+                    user_nick=(await self.services.user.get(uid=message[\"user_uid\"]))[\"nick\"],\n+                    message=message[\"message\"],\n+                    created_at=message[\"created_at\"]\n+                ))\n+            return messages\n+        \n         async def get_channels(self):\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688", "diff": "commit 87895a72d3ddb5f3ca98e4409f251e663e6dd688\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 00:56:16 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nnew file mode 100644\nindex 0000000..f9b7328\n--- /dev/null\n+++ b/src/snek/static/chat-window.js\n@@ -0,0 +1,43 @@\n+\n+\n+class ChatWindowElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n+    }\n+\n+    async connectedCallback() {\n+        const link = document.createElement('link')\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement(\"section\")\n+        this.container.classList.add(\"chat-area\")\n+        this.container.classList.add(\"chat-window\")\n+        \n+        const chatHeader = document.createElement(\"div\")\n+        chatHeader.classList.add(\"chat-header\")\n+        const chatTitle = document.createElement('h2')\n+        chatTitle.classList.add(\"chat-title\")\n+        chatTitle.innerText = \"Loading...\"\n+        chatHeader.appendChild(chatTitle)\n+        this.container.appendChild(chatHeader)\n+        const channels = await app.rpc.getChannels()\n+        const channel = channels[0]\n+        chatTitle.innerText = channel.name \n+        const channelElement = document.createElement('message-list')\n+        channelElement.setAttribute(\"channel\", channel.uid)\n+        this.container.appendChild(channelElement)\n+        this.component.appendChild(this.container)\n+        console.info(channel)\n+        const messages = await app.rpc.getMessages(channel.uid)\n+        console.info(messages)\n+        await app.rpc.sendMessage(channel.uid,\"hello world\")\n+    }\n+\n+\n+}\n+\n+customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and updated app.py", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3", "diff": "commit aec9ffd1a1a49acad8940b793be6ff3abcae07a3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 01:06:13 2025 +0100\n\n    Persistance.\n\ndiff --git a/.gitignore b/.gitignore\nindex ece77be..43eb3eb 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -3,6 +3,7 @@\n .resources\n .backup*\n docs\n+snek.d*\n *.db*\n *.png\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7546ae0..b1c20c0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -107,7 +107,7 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n-app = Application()\n \n if __name__ == \"__main__\":"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Add notification service and related components", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d", "diff": "commit 4f71f745744b1a413a729875bc42366ea3ab665d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 02:57:51 2025 +0100\n\n    Persistance.\n\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex be22534..1841346 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -3,6 +3,7 @@ import functools\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n+from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.system.object import Object\n \n@@ -15,6 +16,7 @@ def get_mappers(app=None):\n             \"channel_member\": ChannelMemberMapper(app=app),\n             \"channel\": ChannelMapper(app=app),\n             \"channel_message\": ChannelMessageMapper(app=app),\n+            \"notification\": NotificationMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex db4a00f..6a8f76c 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -4,6 +4,7 @@ from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n from snek.service.chat import ChatService\n+from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.system.object import Object\n@@ -19,6 +20,7 @@ def get_services(app):\n             \"channel_message\": ChannelMessageService(app=app),\n             \"chat\": ChatService(app=app),\n             \"socket\": SocketService(app=app),\n+            \"notification\": NotificationService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 3dd67e3..74ca94e 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -8,29 +8,14 @@ class ChatService(BaseService):\n \n     async def send(self,user_uid, channel_uid, message):\n         channel_message = await self.services.channel_message.create(\n-            user_uid, \n             channel_uid, \n+            user_uid, \n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n         \n         user = await self.services.user.get(uid=user_uid)\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            model = await self.new()\n-            model[\"object_uid\"] = channel_message_uid\n-            model[\"object_type\"] = \"channel_message\"\n-            model[\"user_uid\"] = channel_member[\"user_uid\"]\n-            model[\"message\"] = (\n-                f\"New message from {user['nick']} in {channel_member['label']}.\"\n-            )\n-            if not await self.services.channel_member.save(model):\n-                raise Exception(f\"Failed to create notification: {model.errors}.\")\n-            \n+        await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n             message=message,\n             user_uid=user_uid,\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex b674b8d..5447efb 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,11 +101,15 @@ main {\n   font-size: 1.2em;\n }\n-\n+message-list {\n+  flex: 1;;\n+   height: 200px;\n+  overflow-y: auto;\n+}\n .chat-messages {\n   flex: 1;\n   padding: 20px;\n-  overflow-y: auto;\n+  height: 200px;\n }\n \ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex f9b7328..35e4e78 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -4,7 +4,7 @@ class ChatWindowElement extends HTMLElement {\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div');\n+        this.component = document.createElement('section');\n         this.shadowRoot.appendChild(this.component);\n     }\n \n@@ -13,6 +13,7 @@ class ChatWindowElement extends HTMLElement {\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n         this.component.appendChild(link)\n+        this.component.classList.add(\"chat-area\")\n         this.container = document.createElement(\"section\")\n         this.container.classList.add(\"chat-area\")\n         this.container.classList.add(\"chat-window\")\n@@ -29,12 +30,33 @@ class ChatWindowElement extends HTMLElement {\n         chatTitle.innerText = channel.name \n         const channelElement = document.createElement('message-list')\n         channelElement.setAttribute(\"channel\", channel.uid)\n         this.container.appendChild(channelElement)\n+\n+        const chatInput = document.createElement('chat-input')\n+        chatInput.classList.add(\"chat-input\")\n+        chatInput.addEventListener(\"submit\",(e)=>{\n+            app.rpc.sendMessage(channel.uid,e.detail)\n+        })\n+        this.container.appendChild(chatInput)\n+\n         this.component.appendChild(this.container)\n         console.info(channel)\n         const messages = await app.rpc.getMessages(channel.uid)\n         console.info(messages)\n-        await app.rpc.sendMessage(channel.uid,\"hello world\")\n+        messages.forEach(message=>{\n+            if(!message['user_nick'])\n+                return\n+            channelElement.addMessage(message)\n+        })\n+        const me = this\n+        channelElement.addEventListener(\"message\",(message)=>{\n+            console.info(\"ROCKSTARTSS\")\n+            setTimeout(()=>{\n+            message.detail.element.scrollIntoView({behavior: 'smooth'})\n+        },10)\n+    })\n+        await app.rpc.sendMessage(channel.uid,\"Grrrrr\")\n     }\n \n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex c1d7b3d..84207d2 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -13,11 +13,12 @@ class MessageListElement extends HTMLElement {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n+        \n         this.shadowRoot.appendChild(this.component )\n     }\n     createElement(message){\n         const element = document.createElement(\"div\")\n-\n+        \n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n@@ -59,15 +60,22 @@ class MessageListElement extends HTMLElement {\n         const element = this.createElement(obj)\n         this.messages.push(obj)\n         this.container.appendChild(element)\n+        const me = this \n+        this.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        \n         return obj\n     }\n+    scrollBottom(){\n+        this.container.scrollTop = this.container.scrollHeight;\n+    }\n     connectedCallback() {\n         const link = document.createElement('link')\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n         this.component.appendChild(link)\n+        this.component.classList.add(\"chat-messages\")\n         this.container = document.createElement('div')\n-        this.container.classList.add(\"chat-messages\")\n         this.component.appendChild(this.container)\n         \n         this.messages = []\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f6635d3..0184a97 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -8,6 +8,7 @@\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>\n   <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n@@ -31,14 +32,7 @@\n       </ul>\n     </aside>\n-    <section>\n     <chat-window class=\"chat-area\"></chat-window>\n-  \n-    <div class=\"chat-input\">\n-        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-        <button>Send</button>\n-      </div>\n-    </section>\n   </main>\n   <script>\n     document.addEventListener(\"DOMContentLoaded\",()=>{\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 6936780..1b08d04 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -17,12 +17,19 @@ class RPCView(BaseView):\n        \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n-            async for message in self.services.channel_message.query(\"SELECT channel_uid, user_uid, message, created_at FROM channel_message WHERE channel_uid = :channel_uid ORDER BY created_at DESC LIMIT 30 OFFSET :offset\",{\"channel_uid\":channel_uid,\"offset\":int(offset)}):\n+                print(\"JEEEHHH\\n\",flush=True)\n+\n+                user = await self.services.user.get(uid=message[\"user_uid\"])\n+                if not user:\n+                    print(\"User not found!\",flush= True)\n+                    continue\n+\n                 messages.append(dict(\n                     uid=message[\"uid\"],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],\n-                    user_nick=(await self.services.user.get(uid=message[\"user_uid\"]))[\"nick\"],\n+                    user_nick=user['nick'],\n                     message=message[\"message\"],\n                     created_at=message[\"created_at\"]\n                 ))"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Add chat input element with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c", "diff": "commit 2a3e225e1dbb40374e841af8977ff19cd4711f0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 02:58:28 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nnew file mode 100644\nindex 0000000..fcf925b\n--- /dev/null\n+++ b/src/snek/static/chat-input.js\n@@ -0,0 +1,40 @@\n+\n+\n+class ChatInputElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n+    }\n+    connectedCallback() {\n+        const link = document.createElement(\"link\")\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement('div')\n+        this.container.classList.add(\"chat-input\")\n+        this.container.innerHTML = `\n+            <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+            <button>Send</button>\n+        `;\n+        this.container.querySelector('textarea').addEventListener('input', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"input\", {detail:e.target.value,bubbles:true}))\n+            const message = e.target.value;\n+            const button = this.container.querySelector('button');\n+            button.disabled = !message;\n+        })\n+        this.container.querySelector('textarea').addEventListener('change',(e)=>{\n+            this.dispatchEvent(new CustomEvent(\"change\", {detail:e.target.value,bubbles:true}))\n+            console.error(e.target.value)\n+        })\n+        this.container.querySelector('textarea').addEventListener('keyup', (e) => {\n+            if(e.key == 'Enter' && !e.shiftKey){\n+                this.dispatchEvent(new CustomEvent(\"submit\", {detail:e.target.value,bubbles:true}))\n+                e.target.value = ''\n+            }\n+        })\n+        this.component.appendChild(this.container)\n+    }\n+}\n+customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a", "diff": "commit 188a1e61783a7d08cb7ece1fbcd332aa1f19672a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:04:43 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 396d11e..c9904f5 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -16,8 +16,9 @@ class LoginView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n+            user = await self.services.user.get(username=form.username.value,deleted_at=None)\n             self.session[\"logged_in\"] = True\n             self.session[\"username\"] = form.username.value\n-            self.session[\"uid\"] = form.uid.value\n+            self.session[\"uid\"] = user[\"uid\"]\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove console.debug statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e", "diff": "commit 26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:16:44 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 1e98ec5..21afc38 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -78,7 +78,7 @@ class Page {\n }\n \n class RESTClient {\n-    debug = true \n+    debug = false \n     \n     async get(url, params){\n         params = params ? params : {} \n@@ -276,7 +276,6 @@ class Socket extends EventHandler {\n         })\n     }\n     onData(data){\n-        console.debug(\"Data received\",data)\n         if(data.callId){\n             this.emit(data.callId, data.data)\n         }\n@@ -331,7 +330,6 @@ class App extends EventHandler {\n         this.rpc = this.ws.client \n         const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            console.debug(\"App channel message!\",data)\n             me.emit(data.channel_uid,data)\n         }) \n     }\n@@ -350,7 +348,8 @@ class App extends EventHandler {\n             }))\n             \n         }\n-        return await Promise.all(promises)\n+    \n     }"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Remove console logs and disable test message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490", "diff": "commit 095e30a92f6d12edf16ca87d66b335088b853490\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:37:04 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 35e4e78..f6b0102 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -41,9 +41,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(chatInput)\n \n         this.component.appendChild(this.container)\n-        console.info(channel)\n         const messages = await app.rpc.getMessages(channel.uid)\n-        console.info(messages)\n         messages.forEach(message=>{\n             if(!message['user_nick'])\n                 return\n@@ -56,7 +54,7 @@ class ChatWindowElement extends HTMLElement {\n             message.detail.element.scrollIntoView({behavior: 'smooth'})\n         },10)\n     })\n-        await app.rpc.sendMessage(channel.uid,\"Grrrrr\")\n+        \n     }\n \n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 84207d2..7e17afb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -82,7 +82,6 @@ class MessageListElement extends HTMLElement {\n         this.channel_uid = this.getAttribute(\"channel\")\n         const me = this\n         app.addEventListener(this.channel_uid, (data) => {\n-            console.info(\"WIIIIIIIIIIIIIIIIIIIIIIII\")\n             me.addMessage(data)\n         })\n         this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove unnecessary benchmark script", "commit": "374db23669e203c98e5335b9a7abe9aff2110537", "diff": "commit 374db23669e203c98e5335b9a7abe9aff2110537\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:38:46 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0184a97..03ef013 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -34,16 +34,5 @@\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>\n-  <script>\n-    document.addEventListener(\"DOMContentLoaded\",()=>{\n-      setTimeout(()=>{\n-      app.benchMark(3).then(result=>{\n-        console.info(\"Benchmarked\")\n-      })\n-    },1000)\n-      \n-    })\n-\n-  </script>\n </body>\n </html>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Improved socket handling and cache logging", "commit": "f3d12a257e7a43e3292654d7f67f05d823f16283", "diff": "commit f3d12a257e7a43e3292654d7f67f05d823f16283\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:48:53 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 55909ce..b7f172a 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -27,6 +27,8 @@ class SocketService(BaseService):\n                 await ws.send_json(message)\n             except Exception as ex:\n                 print(ex)\n+                print(\"Deleting socket.\")\n+                self.subscriptions[channel_uid].remove(ws)\n                 continue \n             count += 1\n         return count\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 86f5557..39e6fc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(\"New version:\", self.version, flush=True)\n+            print(\"Cache store! New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove generated pycache file", "commit": "8e825a90c6e575f114b380312bb9c5726577b8b7", "diff": "commit 8e825a90c6e575f114b380312bb9c5726577b8b7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 05:12:02 2025 +0100\n\n    Deleted pycache.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\ndeleted file mode 100644\nindex 6f355a6..0000000\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and /dev/null differ"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit table results to 30", "commit": "01d8093e7210910016ea5d6d8bbc5d8f2514c14d", "diff": "commit 01d8093e7210910016ea5d6d8bbc5d8f2514c14d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:28:43 2025 +0100\n\n    Added 30 limit on all tables.\n\ndiff --git a/.gitignore b/.gitignore\nindex 43eb3eb..5e03233 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -4,6 +4,8 @@\n .backup*\n docs\n snek.d*\n+.rcontext.txt \n+*.zip\n *.db*\n *.png\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex d86de26..4b1a6f8 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -12,3 +12,5 @@ class ChannelMessageService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n+    \n+    \ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 51e4b9f..baf2086 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -58,6 +58,8 @@ class BaseService:\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n+        if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+            kwargs[\"_limit\"] = 30\n         async for model in self.mapper.find(**kwargs):\n             yield model"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit tables to 30 entries", "commit": "d93d48ef7e023c62bfa9b64ede20cd9f86c3242e", "diff": "commit d93d48ef7e023c62bfa9b64ede20cd9f86c3242e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:32:23 2025 +0100\n\n    Added 30 limit on all tables.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 1b08d04..3ac54a3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -17,7 +17,7 @@ class RPCView(BaseView):\n        \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 print(\"JEEEHHH\\n\",flush=True)\n \n                 user = await self.services.user.get(uid=message[\"user_uid\"])"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Improved message handling and added scheduling for event dispatch", "commit": "da72a15068fe14eeb2b50b4cd3342fb4b70b0c79", "diff": "commit da72a15068fe14eeb2b50b4cd3342fb4b70b0c79\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:52:13 2025 +0100\n\n    Removed pyc files.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex f6b0102..8acf595 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -49,10 +49,8 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            console.info(\"ROCKSTARTSS\")\n-            setTimeout(()=>{\n             message.detail.element.scrollIntoView({behavior: 'smooth'})\n-        },10)\n+        \n     })\n         \n     }\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7e17afb..a45cd6d 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -9,6 +9,7 @@ class MessageListElement extends HTMLElement {\n     room = null\n     url = null\n     container = null\n+    messageEventSchedule = null \n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n@@ -40,6 +41,7 @@ class MessageListElement extends HTMLElement {\n         element.appendChild(avatar)\n         element.appendChild(messageContent)\n \n+    \n \n \n         message.element = element \n@@ -61,8 +63,12 @@ class MessageListElement extends HTMLElement {\n         this.messages.push(obj)\n         this.container.appendChild(element)\n         const me = this \n-        this.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n         \n+        this.messageEventSchedule.delay(() => {\n+            me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        })\n+       \n+\n         return obj\n     }\n     scrollBottom(){\n@@ -77,7 +83,7 @@ class MessageListElement extends HTMLElement {\n         this.container = document.createElement('div')\n         this.component.appendChild(this.container)\n-        \n+        this.messageEventSchedule = new Schedule(500)\n         this.messages = []\n         this.channel_uid = this.getAttribute(\"channel\")\n         const me = this\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nnew file mode 100644\nindex 0000000..fefb503\n--- /dev/null\n+++ b/src/snek/static/schedule.js\n@@ -0,0 +1,45 @@\n+\n+\n+class Schedule {\n+\n+    constructor(msDelay) {\n+        if(!msDelay){\n+            msDelay = 100\n+        }\n+        this.msDelay = msDelay\n+        this._once = false\n+        this.timeOutCount = 0;\n+        this.timeOut = null \n+        this.interval = null \n+    }\n+    cancelRepeat() {\n+        clearInterval(this.interval)\n+        this.interval = null \n+    }\n+    cancelDelay() {\n+        clearTimeout(this.interval)\n+        this.interval = null\n+    }\n+    repeat(func){\n+        if(this.interval){\n+            return false \n+        }\n+        this.interval = setInterval(()=>{\n+            func()\n+        }, this.msDelay)\n+    }\n+    delay(func) {\n+        this.timeOutCount++\n+        if(this.timeOut){\n+            this.cancelDelay()\n+        }\n+        const me = this \n+        this.timeOut = setTimeout(()=>{\n+            clearTimeout(me.timeOut)\n+            me.timeOut = null\n+            me.cancelDelay()\n+            me.timeOutCount = 0\n+        }, this.msDelay)\n+    }\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 03ef013..05ef008 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule functionality and minor UI adjustments", "commit": "99d335ac244c2258d82821344fa517857a782f4a", "diff": "commit 99d335ac244c2258d82821344fa517857a782f4a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:08:18 2025 +0100\n\n    Added schedule.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 5447efb..6fffd16 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -104,6 +104,7 @@ main {\n message-list {\n   flex: 1;;\n    height: 200px;\n+   padding-bottom: 40px;\n   overflow-y: auto;\n }\n .chat-messages {\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 8acf595..0727225 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -1,10 +1,12 @@\n \n \n class ChatWindowElement extends HTMLElement {\n+    receivedHistory = false\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n+        \n         this.shadowRoot.appendChild(this.component);\n     }\n \n@@ -49,9 +51,10 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            message.detail.element.scrollIntoView({behavior: 'smooth'})\n-        \n+            message.detail.element.scrollIntoView()\n+         \n     })\n+\n         \n     }\n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex a45cd6d..29e8a0b 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -10,11 +10,11 @@ class MessageListElement extends HTMLElement {\n     url = null\n     container = null\n     messageEventSchedule = null \n+    observer = null \n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n-        \n         this.shadowRoot.appendChild(this.component )\n     }\n     createElement(message){\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex fefb503..0eb41d1 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -37,6 +37,7 @@ class Schedule {\n         this.timeOut = setTimeout(()=>{\n             clearTimeout(me.timeOut)\n             me.timeOut = null\n+            func(me.timeOutCount)\n             me.cancelDelay()\n             me.timeOutCount = 0\n         }, this.msDelay)\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3ac54a3..514db45 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -18,6 +18,7 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n+                \n                 print(\"JEEEHHH\\n\",flush=True)\n \n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n@@ -25,7 +26,7 @@ class RPCView(BaseView):\n                     print(\"User not found!\",flush= True)\n                     continue\n \n-                messages.append(dict(\n+                messages.insert(0,dict(\n                     uid=message[\"uid\"],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule and benchmark message parameter", "commit": "4f1a48c197fcad25d80873bac55cf66f7ff99382", "diff": "commit 4f1a48c197fcad25d80873bac55cf66f7ff99382\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:11:30 2025 +0100\n\n    Added schedule.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 21afc38..810e6bc 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -333,16 +333,18 @@ class App extends EventHandler {\n             me.emit(data.channel_uid,data)\n         }) \n     }\n-    async benchMark(times) {\n+    async benchMark(times,message) {\n         if(!times)\n             times = 100\n+        if(!message)\n+            message = \"Benchmark Message\"\n         let promises = []\n         const me = this \n         for(let i = 0; i < times; i++){\n             promises.push(this.rpc.getChannels().then(channels=>{\n                 channels.forEach(channel=>{\n-                    me.rpc.sendMessage(channel.uid,`Haha ${i}`).then(data=>{\n-                        console.info(data)\n+                    me.rpc.sendMessage(channel.uid,`${message} ${i}`).then(data=>{\n+                        \n                     })\n                 })\n             }))"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound on new message", "commit": "5aee606d5d65e71afa8366d24ed4632f662a9126", "diff": "commit 5aee606d5d65e71afa8366d24ed4632f662a9126\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:24:10 2025 +0100\n\n    Added notification sound.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 810e6bc..1d56076 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -51,12 +51,12 @@ class Messages {\n \n \n class Room {\n-    name = null \n+    name = null\n     messages = []\n-    constructor(name){\n-        this.name = name \n+    constructor(name) {\n+        this.name = name\n     }\n-    setMessages(list){\n+    setMessages(list) {\n \n     }\n \n@@ -65,9 +65,9 @@ class Room {\n \n \n class InlineAppElement extends HTMLElement {\n-    \n-    constructor(){\n+\n+    constructor() {\n     }\n \n }\n@@ -78,38 +78,38 @@ class Page {\n }\n \n class RESTClient {\n-    debug = false \n-    \n-    async get(url, params){\n-        params = params ? params : {} \n+    debug = false\n+\n+    async get(url, params) {\n+        params = params ? params : {}\n         const encodedParams = new URLSearchParams(params);\n-        if(encodedParams)\n+        if (encodedParams)\n             url += '?' + encodedParams\n-        const response = await fetch(url,{\n+        const response = await fetch(url, {\n             method: 'GET',\n             headers: {\n-              'Content-Type': 'application/json'\n+                'Content-Type': 'application/json'\n             }\n-          });\n-          const result =  await response.json()\n-          if(this.debug){\n-            console.debug({url:url,params:params,result:result})\n-          }\n-        return result \n+        });\n+        const result = await response.json()\n+        if (this.debug) {\n+            console.debug({ url: url, params: params, result: result })\n+        }\n+        return result\n     }\n     async post(url, data) {\n-    const response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify(data)\n-      });\n-\n-      const result =  await response.json()\n-          if(this.debug){\n-            console.debug({url:url,params:params,result:result})\n-          }\n+        const response = await fetch(url, {\n+            method: 'POST',\n+            headers: {\n+                'Content-Type': 'application/json'\n+            },\n+            body: JSON.stringify(data)\n+        });\n+\n+        const result = await response.json()\n+        if (this.debug) {\n+            console.debug({ url: url, params: params, result: result })\n+        }\n         return result\n     }\n }\n@@ -117,17 +117,17 @@ const rest = new RESTClient()\n \n class EventHandler {\n \n-    constructor(){\n+    constructor() {\n         this.subscribers = {}\n     }\n-    addEventListener(type,handler){\n-        if(!this.subscribers[type])\n+    addEventListener(type, handler) {\n+        if (!this.subscribers[type])\n             this.subscribers[type] = []\n         this.subscribers[type].push(handler)\n     }\n-    emit(type,...data){\n-        if(this.subscribers[type])\n-            this.subscribers[type].forEach(handler=>handler(...data))\n+    emit(type, ...data) {\n+        if (this.subscribers[type])\n+            this.subscribers[type].forEach(handler => handler(...data))\n     }\n \n }\n@@ -136,182 +136,182 @@ class Chat extends EventHandler {\n \n     constructor() {\n         super()\n-        this._socket = null \n-        this._wait_connect = null \n+        this._socket = null\n+        this._wait_connect = null\n         this._promises = {}\n     }\n-    connect(){\n-        if(this._wait_connect)\n+    connect() {\n+        if (this._wait_connect)\n             return this._wait_connect\n-        \n-            const me = this \n-            return new Promise(async (resolve,reject)=>{\n-                me._wait_connect = resolve \n-                me._socket = new WebSocket(me._url)\n-                console.debug(\"Connecting..\")\n-                \n-                me._socket.onconnect = ()=>{\n-                    me._connected()\n-                    me._wait_socket(me)\n-                }\n-            }) \n-        \n+\n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            me._wait_connect = resolve\n+            me._socket = new WebSocket(me._url)\n+            console.debug(\"Connecting..\")\n+\n+            me._socket.onconnect = () => {\n+                me._connected()\n+                me._wait_socket(me)\n+            }\n+        })\n+\n     }\n     generateUniqueId() {\n     }\n-    call(method,...args){\n-        const me = this \n-        return new Promise(async (resolve,reject)=>{\n-            try{\n-                const command = {method:method,args:args,message_id:me.generateUniqueId()}\n+    call(method, ...args) {\n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            try {\n+                const command = { method: method, args: args, message_id: me.generateUniqueId() }\n                 me._promises[command.message_id] = resolve\n-                await me._socket.send(JSON.stringify(command)) \n-               \n-            }catch(e){\n+                await me._socket.send(JSON.stringify(command))\n+\n+            } catch (e) {\n                 reject(e)\n             }\n         })\n     }\n     _connected() {\n-        const me = this \n+        const me = this\n         this._socket.onmessage = (event) => {\n             const message = JSON.parse(event.data)\n-            if(message.message_id && me._promises[message.message_id]){\n+            if (message.message_id && me._promises[message.message_id]) {\n                 me._promises[message.message_id](message)\n                 delete me._promises[message.message_id]\n-            }else{\n-            me.emit(\"message\",me, message)\n+            } else {\n+                me.emit(\"message\", me, message)\n             }\n         }\n         this._socket.onclose = (event) => {\n-            me._wait_socket = null \n-            me._socket = null \n-            me.emit('close',me)\n+            me._wait_socket = null\n+            me._socket = null\n+            me.emit('close', me)\n         }\n     }\n \n     async privmsg(room, text) {\n-        await rest.post(\"/api/privmsg\",{\n-            room:room,\n-            text:text\n+        await rest.post(\"/api/privmsg\", {\n+            room: room,\n+            text: text\n         })\n     }\n \n }\n \n class Socket extends EventHandler {\n-    ws = null \n+    ws = null\n     isConnected = null\n     isConnecting = null\n     url = null\n     connectPromises = []\n     constructor() {\n         super()\n-        this.ensureConnection() \n+        this.ensureConnection()\n     }\n     _camelToSnake(str) {\n         return str\n-          .replace(/([a-z])([A-Z])/g, '$1_$2') \n-          .toLowerCase(); \n+            .replace(/([a-z])([A-Z])/g, '$1_$2')\n+            .toLowerCase();\n     }\n     get client() {\n         const me = this\n         const proxy = new Proxy(\n             {},\n             {\n-            get(target, prop) {\n-                return (...args) => {\n-                  let functionName = me._camelToSnake(prop)\n-                  return me.call(functionName, ...args);\n-                };\n-              },\n+                get(target, prop) {\n+                    return (...args) => {\n+                        let functionName = me._camelToSnake(prop)\n+                        return me.call(functionName, ...args);\n+                    };\n+                },\n             }\n-          );\n+        );\n         return proxy\n     }\n-    ensureConnection(){\n+    ensureConnection() {\n         return this.connect()\n     }\n     generateUniqueId() {\n-        return 'id-' + Math.random().toString(36).substr(2, 9); \n+        return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n-    connect(){\n-        const me  = this \n-        if(!this.isConnected && !this.isConnecting){\n-            this.isConnecting = true \n-        }else if (this.isConnecting){\n-            return new Promise((resolve,reject)=>{\n+    connect() {\n+        const me = this\n+        if (!this.isConnected && !this.isConnecting) {\n+            this.isConnecting = true\n+        } else if (this.isConnecting) {\n+            return new Promise((resolve, reject) => {\n                 me.connectPromises.push(resolve)\n-            }) \n-        }else if(this.isConnected){\n-            return new Promise((resolve,reject)=>{\n+            })\n+        } else if (this.isConnected) {\n+            return new Promise((resolve, reject) => {\n                 resolve(me)\n             })\n         }\n-        return new Promise((resolve,reject)=>{\n+        return new Promise((resolve, reject) => {\n             me.connectPromises.push(resolve)\n-            \n+\n             const ws = new WebSocket(this.url)\n             ws.onopen = (event) => {\n-                me.ws = ws \n-                me.isConnected = true \n+                me.ws = ws\n+                me.isConnected = true\n                 me.isConnecting = false\n                 ws.onmessage = (event) => {\n                     me.onData(JSON.parse(event.data))\n                 }\n-                ws.onclose = (event) =>{\n+                ws.onclose = (event) => {\n                     me.onClose()\n-                \n+\n                 }\n-                me.connectPromises.forEach(resolve=>{\n-                  resolve(me)  \n+                me.connectPromises.forEach(resolve => {\n+                    resolve(me)\n                 })\n             }\n         })\n     }\n-    onData(data){\n-        if(data.callId){\n+    onData(data) {\n+        if (data.callId) {\n             this.emit(data.callId, data.data)\n         }\n-        if(data.channel_uid){\n-            this.emit(data.channel_uid,data.data)\n-            this.emit(\"channel-message\",data)\n+        if (data.channel_uid) {\n+            this.emit(data.channel_uid, data.data)\n+            this.emit(\"channel-message\", data)\n         }\n-        \n+\n     }\n-    async sendJson(data){\n-        return await this.connect().then((api)=>{\n+    async sendJson(data) {\n+        return await this.connect().then((api) => {\n             api.ws.send(JSON.stringify(data))\n         })\n     }\n-    async call(method,...args){\n-        const call= {\n+    async call(method, ...args) {\n+        const call = {\n             callId: this.generateUniqueId(),\n             method: method,\n             args: args\n         }\n-        \n-        const me = this \n-        return new Promise(async(resolve,reject)=>{\n-                me.addEventListener(call.callId,(data)=>{\n-                    resolve(data)\n-                })\n-                await me.sendJson(call)\n-            \n \n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            me.addEventListener(call.callId, (data) => {\n+                resolve(data)\n             })\n+            await me.sendJson(call)\n+\n+\n+        })\n     }\n-    onClose(){\n+    onClose() {\n         console.info(\"Connection lost. Reconnecting.\")\n-        this.isConnected = false \n+        this.isConnected = false\n         this.isConnecting = false\n-        this.ensureConnection().then(()=>{\n+        this.ensureConnection().then(() => {\n             console.info(\"Reconnected.\")\n         })\n     }\n@@ -320,38 +320,53 @@ class Socket extends EventHandler {\n \n class App extends EventHandler {\n     rooms = []\n-    rest = rest \n-    ws = null \n-    rpc = null \n+    rest = rest\n+    ws = null\n+    rpc = null\n+    sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n+    playSound(soundIndex) {\n+        if (!soundIndex)\n+            soundIndex = 0\n+\n+        const player = new Audio(this.sounds[soundIndex]);\n+\n+        player.play()\n+            .then(() => {\n+                console.debug(\"Gave sound notification\")\n+            })\n+            .catch((error) => {\n+                console.error(\"Notification failed:\", error);\n+            });\n+    }\n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n-        this.rpc = this.ws.client \n-        const me = this \n+        this.rpc = this.ws.client\n+        const me = this\n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid,data)\n-        }) \n+            me.emit(data.channel_uid, data)\n+        })\n     }\n-    async benchMark(times,message) {\n-        if(!times)\n+    async benchMark(times, message) {\n+        if (!times)\n             times = 100\n-        if(!message)\n+        if (!message)\n             message = \"Benchmark Message\"\n         let promises = []\n-        const me = this \n-        for(let i = 0; i < times; i++){\n-            promises.push(this.rpc.getChannels().then(channels=>{\n-                channels.forEach(channel=>{\n-                    me.rpc.sendMessage(channel.uid,`${message} ${i}`).then(data=>{\n-                        \n+        const me = this\n+        for (let i = 0; i < times; i++) {\n+            promises.push(this.rpc.getChannels().then(channels => {\n+                channels.forEach(channel => {\n+                    me.rpc.sendMessage(channel.uid, `${message} ${i}`).then(data => {\n+\n                     })\n                 })\n             }))\n-            \n+\n         }\n-    \n+\n     }\n \n \ndiff --git a/src/snek/static/audio/soundfx.d_alarm1.mp3 b/src/snek/static/audio/soundfx.d_alarm1.mp3\nnew file mode 100644\nindex 0000000..372e3b4\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_alarm1.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_alarm2.mp3 b/src/snek/static/audio/soundfx.d_alarm2.mp3\nnew file mode 100644\nindex 0000000..85bb50a\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_alarm2.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep1.mp3 b/src/snek/static/audio/soundfx.d_beep1.mp3\nnew file mode 100644\nindex 0000000..0aae9fd\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep1.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep2.mp3 b/src/snek/static/audio/soundfx.d_beep2.mp3\nnew file mode 100644\nindex 0000000..171f0e4\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep2.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep3.mp3 b/src/snek/static/audio/soundfx.d_beep3.mp3\nnew file mode 100644\nindex 0000000..883c5d3\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep3.mp3 differ\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0727225..9e0e648 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -51,6 +51,7 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n+            app.playSound(0)\n             message.detail.element.scrollIntoView()\n          \n     })\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 29e8a0b..a6fc835 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -66,6 +66,7 @@ class MessageListElement extends HTMLElement {\n         \n         this.messageEventSchedule.delay(() => {\n             me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        \n         })"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound and improved chat input functionality", "commit": "14c59ba5c0abc7d1331e022cc99222223ea21526", "diff": "commit 14c59ba5c0abc7d1331e022cc99222223ea21526\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:37:10 2025 +0100\n\n    Added notification sound.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex b7f172a..0c11208 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -22,7 +22,8 @@ class SocketService(BaseService):\n     async def broadcast(self, channel_uid, message):\n         print(\"BROADCAT!\",message)\n         count = 0\n-        for ws in self.subscriptions.get(channel_uid,[]):\n+        subscriptions = set(self.subscriptions.get(channel_uid,[]))\n+        for ws in subscriptions:\n             try:\n                 await ws.send_json(message)\n             except Exception as ex:\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex fcf925b..1ea2c2e 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -8,6 +8,7 @@ class ChatInputElement extends HTMLElement {\n         this.shadowRoot.appendChild(this.component);\n     }\n     connectedCallback() {\n+        const me = this\n         const link = document.createElement(\"link\")\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n@@ -18,22 +19,31 @@ class ChatInputElement extends HTMLElement {\n             <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n             <button>Send</button>\n         `;\n-        this.container.querySelector('textarea').addEventListener('input', (e) => {\n-            this.dispatchEvent(new CustomEvent(\"input\", {detail:e.target.value,bubbles:true}))\n+        this.textBox = this.container.querySelector('textarea')\n+        this.textBox.addEventListener('input', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"input\", { detail: e.target.value, bubbles: true }))\n             const message = e.target.value;\n             const button = this.container.querySelector('button');\n             button.disabled = !message;\n         })\n-        this.container.querySelector('textarea').addEventListener('change',(e)=>{\n-            this.dispatchEvent(new CustomEvent(\"change\", {detail:e.target.value,bubbles:true}))\n+        this.textBox.addEventListener('change', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n-        this.container.querySelector('textarea').addEventListener('keyup', (e) => {\n-            if(e.key == 'Enter' && !e.shiftKey){\n-                this.dispatchEvent(new CustomEvent(\"submit\", {detail:e.target.value,bubbles:true}))\n+        this.textBox.addEventListener('keyup', (e) => {\n+            if (e.key == 'Enter' && !e.shiftKey) {\n+                this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n             }\n         })\n+\n+        this.container.querySelector('button').addEventListener('click', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n+            setTimeout(()=>{\n+                me.textBox.value = ''\n+                me.textBox.focus()\n+            },200)\n+        })\n         this.component.appendChild(this.container)\n     }\n }\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 9e0e648..c5e3d61 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -36,7 +36,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(channelElement)\n \n         const chatInput = document.createElement('chat-input')\n-        chatInput.classList.add(\"chat-input\")\n+        \n         chatInput.addEventListener(\"submit\",(e)=>{\n             app.rpc.sendMessage(channel.uid,e.detail)\n         })"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Improved WebSocket connection handling and PWA install prompt", "commit": "b2ca373081bdd7514b0f849dc1033edfd3f76424", "diff": "commit b2ca373081bdd7514b0f849dc1033edfd3f76424\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 20:41:24 2025 +0100\n\n    Reconnector.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 1d56076..a2d45d4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -148,9 +148,17 @@ class Chat extends EventHandler {\n         const me = this\n         return new Promise(async (resolve, reject) => {\n             me._wait_connect = resolve\n-            me._socket = new WebSocket(me._url)\n             console.debug(\"Connecting..\")\n \n+            try {\n+                me._socket = new WebSocket(me._url)\n+            }catch(e){\n+                console.warning(e)\n+                setTimeout(()=>{\n+                    me.ensureConnection()\n+                },1000)\n+            }\n+\n             me._socket.onconnect = () => {\n                 me._connected()\n                 me._wait_socket(me)\n@@ -210,6 +218,7 @@ class Socket extends EventHandler {\n     isConnecting = null\n     url = null\n     connectPromises = []\n+    ensureTimer = null \n     constructor() {\n         super()\n@@ -236,6 +245,14 @@ class Socket extends EventHandler {\n         return proxy\n     }\n     ensureConnection() {\n+        if(this.ensureTimer)\n+            return this.connect()\n+        const me  = this \n+        this.ensureTimer = setInterval(()=>{\n+            if (me.isConnecting)\n+                me.isConnecting = false\n+            me.connect()\n+        },5000)\n         return this.connect()\n     }\n     generateUniqueId() {\n@@ -256,9 +273,11 @@ class Socket extends EventHandler {\n         }\n         return new Promise((resolve, reject) => {\n             me.connectPromises.push(resolve)\n-\n+            console.debug(\"Connecting..\")\n+            \n             const ws = new WebSocket(this.url)\n-            ws.onopen = (event) => {\n+               \n+                ws.onopen = (event) => {\n                 me.ws = ws\n                 me.isConnected = true\n                 me.isConnecting = false\n@@ -269,6 +288,9 @@ class Socket extends EventHandler {\n                     me.onClose()\n \n                 }\n+                ws.onerror = (event)=>{\n+                    me.onClose()\n+                }\n                 me.connectPromises.forEach(resolve => {\n                     resolve(me)\n                 })\n@@ -290,6 +312,7 @@ class Socket extends EventHandler {\n             api.ws.send(JSON.stringify(data))\n         })\n     }\n+\n     async call(method, ...args) {\n         const call = {\n             callId: this.generateUniqueId(),\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 6fffd16..4f702b6 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -125,9 +125,9 @@ message-list {\n   align-items: flex-start;\n   margin-bottom: 15px;\n   padding: 10px;\n   border-radius: 8px;\n-  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);\n }\n \n .chat-messages .message .avatar {\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex c5e3d61..0d008ee 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -22,6 +22,16 @@ class ChatWindowElement extends HTMLElement {\n         \n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n+        let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+          });\n+        \n+        \n         const chatTitle = document.createElement('h2')\n         chatTitle.classList.add(\"chat-title\")\n         chatTitle.innerText = \"Loading...\"\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 05ef008..c2b27ee 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -12,6 +12,8 @@\n   <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n+  <link rel=\"manifest\" href=\"manifest.json\" />\n+\n </head>\n <body>\n   <header>"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added favicon and manifest for PWA support", "commit": "7d05bd9da45489c02a9b057eef86d45e2ca90049", "diff": "commit 7d05bd9da45489c02a9b057eef86d45e2ca90049\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 20:52:37 2025 +0100\n\n    Favicon.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nnew file mode 100644\nindex 0000000..d2e58f2\n--- /dev/null\n+++ b/src/snek/static/manifest.json\n@@ -0,0 +1,11 @@\n+{\n+    \"name\": \"Snek\",\n+    \"description\": \"Danger noodle\",\n+    \"icons\": [\n+      {\n+        \"src\": \"/image/snek1.png\",\n+        \"type\": \"image/png\",\n+        \"sizes\": \"512x512\"\n+      }\n+    ]\n+  }\n\\ No newline at end of file\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c2b27ee..f001813 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,8 +11,9 @@\n   <script src=\"/message-list-manager.js\"></script>\n   <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n-  <link rel=\"stylesheet\" href=\"base.css\">\n-  <link rel=\"manifest\" href=\"manifest.json\" />\n+  <link rel=\"stylesheet\" href=\"/base.css\">\n+  <link rel=\"manifest\" href=\"/manifest.json\" />\n+  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n \n </head>\n <body>"}
{"repo": ".", "date": "2025-01-28", "line": "docs: Added display and start_url to manifest", "commit": "4da635502bca60efd0cc59aa4df236d7b99c2ec2", "diff": "commit 4da635502bca60efd0cc59aa4df236d7b99c2ec2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:01:51 2025 +0100\n\n    Updated manifest.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex d2e58f2..6d98b90 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -1,6 +1,8 @@\n {\n     \"name\": \"Snek\",\n     \"description\": \"Danger noodle\",\n+    \"display\": \"standalone\",\n+    \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n         \"src\": \"/image/snek1.png\","}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Reduced padding in chat messages", "commit": "d69c75c6197e857ad61e4dbc872b5ab5872c4837", "diff": "commit d69c75c6197e857ad61e4dbc872b5ab5872c4837\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:43:48 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4f702b6..9a7eb32 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -109,7 +109,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n-  padding: 20px;\n+  padding: 10px;\n   height: 200px;\n }\n@@ -123,8 +123,8 @@ message-list {\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\n-  margin-bottom: 15px;\n-  padding: 10px;\n+  margin-bottom: 0px;\n+  padding: 5px;\n   border-radius: 8px;"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Add data attributes to message elements", "commit": "9e94210bc3f3b1b614a198591c52f404d84a8be2", "diff": "commit 9e94210bc3f3b1b614a198591c52f404d84a8be2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:54:53 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex a6fc835..71b6b7c 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -19,7 +19,12 @@ class MessageListElement extends HTMLElement {\n     }\n     createElement(message){\n         const element = document.createElement(\"div\")\n-        \n+        element.dataset.uid = message.uid\n+        element.dataset.channel_uid = message.channel_uid\n+        element.dataset.user_nick = message.user_nick\n+        element.dataset.created_at = message.created_at\n+        element.dataset.user_uid = message.user_uid\n+        element.dataset.message = message.message \n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added user color and updated message display", "commit": "84e5bac1b93d5d1c124d303e6b08a29baaf4977c", "diff": "commit 84e5bac1b93d5d1c124d303e6b08a29baaf4977c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:33:00 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 97070c4..f611d6a 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -12,11 +12,17 @@ class UserModel(BaseModel):\n     )\n     nick = ModelField(\n         name=\"nick\",\n-        required=False,\n+        required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n     )\n+    color = ModelField(\n+        name =\"color\",\n+        required=True,\n+        kind=str\n+    )\n     email = ModelField(\n         name=\"email\",\n         required=False,\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 6a8f76c..97fbaae 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -7,6 +7,7 @@ from snek.service.chat import ChatService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n+from snek.service.util import UtilService\n from snek.system.object import Object\n \n \n@@ -21,6 +22,7 @@ def get_services(app):\n             \"chat\": ChatService(app=app),\n             \"socket\": SocketService(app=app),\n             \"notification\": NotificationService(app=app),\n+            \"util\": UtilService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 74ca94e..fcbf0bc 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -19,6 +19,7 @@ class ChatService(BaseService):\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n             message=message,\n             user_uid=user_uid,\n+            color=user['color'],\n             channel_uid=channel_uid,\n             created_at=channel_message[\"created_at\"], \n             updated_at=None,\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 60825a5..eb14ee7 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -13,11 +13,17 @@ class UserService(BaseService):\n             return False\n         return True\n \n+    async def save(self, user):\n+        if not user['color']:\n+            user['color'] = await self.services.util.random_light_hex_color()\n+        return await super().save(user)\n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n         model[\"nick\"] = username\n+        model['color'] = await self.services.util.random_light_hex_color()\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 71b6b7c..0850a25 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -20,6 +20,7 @@ class MessageListElement extends HTMLElement {\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n+        element.dataset.color = message.color\n         element.dataset.channel_uid = message.channel_uid\n         element.dataset.user_nick = message.user_nick\n         element.dataset.created_at = message.created_at\n@@ -28,11 +29,13 @@ class MessageListElement extends HTMLElement {\n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n+        avatar.style.backgroundColor = message.color\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")\n         const author = document.createElement(\"div\")\n         author.classList.add(\"author\")\n+        author.style.color = message.color\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n@@ -60,6 +63,7 @@ class MessageListElement extends HTMLElement {\n             message.channel_uid,\n             message.user_uid,\n             message.user_nick,\n+            message.color,\n             message.message,\n             message.created_at,\n             message.updated_at\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex 6589279..a28262a 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -5,11 +5,13 @@ class MessageModel {\n     created_at = null \n     updated_at = null  \n     element = null \n-    constructor(uid, channel_uid,user_uid,user_nick, message,created_at, updated_at){\n+    color = null\n+    constructor(uid, channel_uid,user_uid,user_nick, color,message,created_at, updated_at){\n         this.uid = uid \n         this.message = message \n         this.user_uid = user_uid \n         this.user_nick = user_nick\n+        this.color = color\n         this.channel_uid = channel_uid  \n         this.created_at = created_at\n         this.updated_at = updated_at\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 9722534..d7e4163 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -48,6 +48,7 @@ class BaseMapper:\n     async def save(self, model: BaseModel) -> bool:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        model.updated_at.update()\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex c9904f5..db5be55 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -16,9 +16,12 @@ class LoginView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            user = await self.services.user.get(username=form.username.value,deleted_at=None)\n+            user = await self.services.user.get(username=form['username'],deleted_at=None)\n+            await self.services.user.save(user)\n             self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = form.username.value\n+            self.session[\"username\"] = user['username']\n             self.session[\"uid\"] = user[\"uid\"]\n+            self.session[\"color\"] = user[\"color\"]\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex eb1c8d8..c29d855 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -22,5 +22,5 @@ class RegisterView(BaseFormView):\n         self.request.session[\"uid\"] = result[\"uid\"]\n         self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n-\n+        self.request.session[\"color\"] = result[\"color\"]\n         return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 514db45..d4ed660 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -14,13 +14,19 @@ class RPCView(BaseView):\n             self.user_uid = self.view.session.get(\"uid\")\n             self.ws = ws \n             \n-       \n+        async def get_user(self, user_uid):\n+            if not user_uid:\n+                user_uid = self.user_uid\n+            user = await self.services.user.get(uid=user_uid)\n+            record = user.record\n+            del record['password']\n+            del record['deleted_at']\n+            del record['email']\n+            return record \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 \n-                print(\"JEEEHHH\\n\",flush=True)\n-\n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:\n                     print(\"User not found!\",flush= True)\n@@ -28,6 +34,7 @@ class RPCView(BaseView):\n \n                 messages.insert(0,dict(\n                     uid=message[\"uid\"],\n+                    color=user['color'],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],\n                     user_nick=user['nick'],\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex a307dee..04ea4d9 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -33,6 +33,7 @@ class StatusView(BaseView):\n                 \"email\": user[\"email\"],\n                 \"nick\": user[\"nick\"],\n                 \"uid\": user[\"uid\"],\n+                \"color\": user['color'],\n                 \"memberships\": memberships,\n             }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added UtilService with random light hex color generator", "commit": "284d38096c7c5b1201f261ec7a5a28ed457952b5", "diff": "commit 284d38096c7c5b1201f261ec7a5a28ed457952b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:33:11 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nnew file mode 100644\nindex 0000000..5550a8c\n--- /dev/null\n+++ b/src/snek/service/util.py\n@@ -0,0 +1,15 @@\n+import random\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class UtilService(BaseService):\n+    \n+    async def random_light_hex_color(self):\n+   \n+        r = random.randint(128, 255)\n+        g = random.randint(128, 255)\n+        b = random.randint(128, 255)\n+        \n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added avatar color and text color", "commit": "93b2f6cc41f08e21241642976b90e3dd98dc37ec", "diff": "commit 93b2f6cc41f08e21241642976b90e3dd98dc37ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:35:21 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 0850a25..7f3c61b 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -30,6 +30,7 @@ class MessageListElement extends HTMLElement {\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color\n+        avatar.style.color= \"black\"\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added linkify functionality to message text", "commit": "9f652ece1bf0498f9032f94b77becc96b6eff009", "diff": "commit 9f652ece1bf0498f9032f94b77becc96b6eff009\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:46:11 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7f3c61b..e60cda1 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -17,6 +17,14 @@ class MessageListElement extends HTMLElement {\n         this.component = document.createElement('div')\n         this.shadowRoot.appendChild(this.component )\n     }\n+    linkifyText(text) {\n+            const urlRegex = /https?:\\/\\/[^\\s]+/g;\n+        \n+            return text.replace(urlRegex, (url) => {\n+                return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n+            });\n+        \n+    }\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n@@ -40,7 +48,7 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        text.textContent = message.message\n+        text.innerHTML = this.linkifyText(message.message)\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Increased default limit and fixed message retrieval", "commit": "16afbb4e15f370babeedfc2aa917daa0292da5a6", "diff": "commit 16afbb4e15f370babeedfc2aa917daa0292da5a6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:48:21 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex baf2086..a088854 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -59,7 +59,7 @@ class BaseService:\n \n     async def find(self, **kwargs):\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n-            kwargs[\"_limit\"] = 30\n+            kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n             yield model\n \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d4ed660..97d58f4 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -25,7 +25,7 @@ class RPCView(BaseView):\n             return record \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 \n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide avatar and author until switch-user message", "commit": "41927b7ef439424326cc58e3939f476e04b8eabb", "diff": "commit 41927b7ef439424326cc58e3939f476e04b8eabb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:55:17 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9a7eb32..38d3288 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -208,3 +208,30 @@ message-list {\n   }\n }\n \n+.message {\n+  .avatar {\n+      opacity: 0;\n+  }\n+\n+  .author {\n+      display: none;\n+  }\n+\n+  .time {\n+      display: none;\n+  }\n+}\n+.message.switch-user {\n+  .avatar {\n+      opacity: 1;\n+  }\n+  .author {\n+      display: block;\n+  }\n+}\n+\n+.message:has(+ .message.switch-user) {\n+  .time {\n+      display: block;\n+  }\n+}"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and switch-user class for message differentiation", "commit": "75ec590be5fc3f446c97549d90c135966142ac25", "diff": "commit 75ec590be5fc3f446c97549d90c135966142ac25\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 01:04:54 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex e60cda1..8c8cf7f 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -35,6 +35,11 @@ class MessageListElement extends HTMLElement {\n         element.dataset.user_uid = message.user_uid\n         element.dataset.message = message.message \n         element.classList.add(\"message\")\n+        if(!this.messages.length){\n+            element.classList.add(\"switch-user\")\n+        }else if (this.messages[this.messages.length-1].user_uid != message.user_uid){\n+            element.classList.add(\"switch-user\")\n+        }\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Show message time on last message and switch user messages", "commit": "0e821f8b588def99f950fecb9369456cff086e0b", "diff": "commit 0e821f8b588def99f950fecb9369456cff086e0b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 01:06:28 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 38d3288..4b1e603 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -230,8 +230,9 @@ message-list {\n   }\n }\n \n-.message:has(+ .message.switch-user) {\n-  .time {\n-      display: block;\n-  }\n+.message:has(+ .message.switch-user), .message:last-child\n+ {\n+    .time {\n+        display: block;\n+    }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and URL handling improvements", "commit": "931aae5134cad80bf7f5ba87fe215a03761f081b", "diff": "commit 931aae5134cad80bf7f5ba87fe215a03761f081b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:45:18 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 75f800e..8198a37 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -10,7 +10,10 @@ class HTMLFrame extends HTMLElement {\n         this.container.classList.add(\"html_frame\")\n       const url = this.getAttribute('url');\n       if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!fullUrl.startsWith(\"https\")){\n+        }\n         if(!url.startsWith(\"/\"))\n             fullUrl.searchParams.set('url', url)   \n         this.loadAndRender(fullUrl.toString());"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added html-frame.js script tag", "commit": "c558dc2d79b90e7424cf4311747f077332b0a193", "diff": "commit c558dc2d79b90e7424cf4311747f077332b0a193\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:47:00 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f001813..8df6e3c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script sr==\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected typo in script source path", "commit": "5f3dac8bc6b702735383688de44ad7609264742a", "diff": "commit 5f3dac8bc6b702735383688de44ad7609264742a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:48:16 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df6e3c..1396ac1 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,7 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n-  <script sr==\"/html-frame.js\"></script>\n+  <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added URL validation and handling for relative URLs", "commit": "4442f75ec50d3d27cfae1702459d5f8f34ba415b", "diff": "commit 4442f75ec50d3d27cfae1702459d5f8f34ba415b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:52:53 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 8198a37..be12a09 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -8,12 +8,13 @@ class HTMLFrame extends HTMLElement {\n \n     connectedCallback() {\n         this.container.classList.add(\"html_frame\")\n-      const url = this.getAttribute('url');\n+      let url = this.getAttribute('url');\n+      if(!url.startsWith(\"https\")){\n+      }\n       if (url) {\n         let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!fullUrl.startsWith(\"https\")){\n-        }\n+        \n         if(!url.startsWith(\"/\"))\n             fullUrl.searchParams.set('url', url)   \n         this.loadAndRender(fullUrl.toString());"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding to HTMLFrame component", "commit": "030942db0984ac0f3a4072581d58d81fad03ef91", "diff": "commit 030942db0984ac0f3a4072581d58d81fad03ef91\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:53:51 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex be12a09..61369e3 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -10,7 +10,7 @@ class HTMLFrame extends HTMLElement {\n         this.container.classList.add(\"html_frame\")\n       let url = this.getAttribute('url');\n       if(!url.startsWith(\"https\")){\n       }\n       if (url) {\n         let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install prompt and button for PWA installation", "commit": "438fad301447e3265ff7484606f8222b271e4d9d", "diff": "commit 438fad301447e3265ff7484606f8222b271e4d9d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:43:42 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a2d45d4..a125c4e 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -298,6 +298,10 @@ class Socket extends EventHandler {\n         })\n     }\n     onData(data) {\n+        if(data.success != undefined && !data.success){\n+            console.error(data)\n+        }\n+\n         if (data.callId) {\n             this.emit(data.callId, data.data)\n         }\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0d008ee..bdad37e 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -22,14 +22,7 @@ class ChatWindowElement extends HTMLElement {\n         \n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n-        let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n-          });\n+        \n         \n         \n         const chatTitle = document.createElement('h2')\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1396ac1..c9ed40d 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -35,9 +35,28 @@\n+       \n       </ul>\n+      <fancy-button id=\"install-button\" style=\"display:none\" text=\"Install\">Install</fancy-button>\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>\n+  <script>\n+let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+           \n+            const button = document.getElementById(\"install-button\")\n+            button.addEventListener(\"click\", async ()=>{  \n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+            })\n+            button.style.display = 'block'\n+            \n+          });\n+        ;\n+    </script>\n </body>\n </html>\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 97d58f4..af04516 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -4,26 +4,60 @@ from snek.system.view import BaseView\n \n class RPCView(BaseView):\n \n-    login_required = True\n-\n     class RPCApi:\n         def __init__(self,view, ws):\n             self.view = view \n             self.app = self.view.app\n             self.services = self.app.services\n-            self.user_uid = self.view.session.get(\"uid\")\n             self.ws = ws \n+\n+        @property\n+        def user_uid(self):\n+            return self.view.session.get(\"uid\")\n             \n+\n+        @property \n+        def request(self):\n+            return self.view.request \n+        \n+        def _require_login(self):\n+            if not self.is_logged_in:\n+                raise Exception(\"Not logged in\")\n+\n+        @property \n+        def is_logged_in(self):\n+            return self.view.session.get(\"logged_in\", False)\n+\n+        async def login(self, username, password):\n+            success = await self.services.user.validate_login(username, password)\n+            if not success:\n+                raise Exception(\"Invalid username or password\")\n+            user = await self.services.user.get(username=username)\n+            self.view.session[\"uid\"] = user[\"uid\"]\n+            self.view.session[\"logged_in\"] = True\n+            self.view.session[\"username\"] = user[\"username\"]\n+            self.view.session[\"user_nick\"] = user[\"nick\"]\n+            record = user.record\n+            del record['password']\n+            del record['deleted_at']\n+            await self.services.socket.add(self.ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n+       \n+            return record \n         async def get_user(self, user_uid):\n+            self._require_login()\n             if not user_uid:\n                 user_uid = self.user_uid\n             user = await self.services.user.get(uid=user_uid)\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            del record['email']\n+            if not user_uid == user[\"uid\"]:\n+                del record['email']\n             return record \n         async def get_messages(self, channel_uid,offset=0):\n+            self._require_login()\n             messages = []\n                 \n@@ -44,6 +78,7 @@ class RPCView(BaseView):\n             return messages\n         \n         async def get_channels(self):\n+            self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n                 channels.append(dict(\n@@ -55,11 +90,13 @@ class RPCView(BaseView):\n             return channels\n \n         async def send_message(self, room, message):\n+            self._require_login()\n             await self.services.chat.send(self.user_uid,room,message)\n             return True \n         \n \n         async def echo(self,*args):\n+            self._require_login()\n             return args\n \n \n@@ -67,16 +104,21 @@ class RPCView(BaseView):\n \n \n         async def __call__(self, data):\n-            call_id = data.get(\"callId\")\n-            method_name = data.get(\"method\")\n-            args = data.get(\"args\")\n-            if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n-\n-            method = getattr(self,method_name.replace(\".\",\"_\"),None)\n-            result = await method(*args)\n-            await self.ws.send_json({\"callId\":call_id,\"data\":result})\n-\n+            try:\n+                call_id = data.get(\"callId\")\n+                method_name = data.get(\"method\")\n+                if method_name.startswith(\"_\"):\n+                    raise Exception(\"Not allowed\")\n+                args = data.get(\"args\")\n+                if hasattr(super(),method_name) or not hasattr(self,method_name):\n+                    return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+                method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+                if not method:\n+                    raise Exception(\"Method not found\")\n+                result = await method(*args)\n+                await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+            except Exception as ex:\n+                await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n     async def call_ping(self,callId,*args):\n         return {\"pong\": args}\n@@ -87,9 +129,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        await self.services.socket.add(ws)\n-        async for subscription in self.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-            await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n+        if self.request.session.get(\"logged_in\") is True:\n+            await self.services.socket.add(ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to the web interface", "commit": "3e4b6b00620f8cf2c8b8c63918e6c93d2987174d", "diff": "commit 3e4b6b00620f8cf2c8b8c63918e6c93d2987174d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:45:14 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c9ed40d..1b671bc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,7 +22,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">Home</a>\n       <a href=\"/logout.html\">Logout</a>\n     </nav>\n@@ -37,7 +37,6 @@\n        \n       </ul>\n-      <fancy-button id=\"install-button\" style=\"display:none\" text=\"Install\">Install</fancy-button>\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to web interface", "commit": "1f5dc57d6f24b17fa66ab5692038e005cc444378", "diff": "commit 1f5dc57d6f24b17fa66ab5692038e005cc444378\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:45:26 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1b671bc..1d660f6 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,7 +22,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">Home</a>\n       <a href=\"/logout.html\">Logout</a>\n     </nav>"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render messages with HTML for rich formatting", "commit": "03c72e85f72207a7b2480f881f7b0cb7055c5feb", "diff": "commit 03c72e85f72207a7b2480f881f7b0cb7055c5feb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:30:54 2025 +0100\n\n    Markdown.\n\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0fab568..0bda0bc 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -5,3 +5,4 @@ class ChannelMessageModel(BaseModel):\n     channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     message = ModelField(name=\"message\", required=True, kind=str)\n+    html = ModelField(name=\"html\", required=False, kind=str)\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 4b1a6f8..8765d53 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,14 +1,38 @@\n from snek.system.service import BaseService\n-\n+import jinja2 \n \n class ChannelMessageService(BaseService):\n     mapper_name = \"channel_message\"\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n+        \n         model[\"channel_uid\"] = channel_uid\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n+                \n+        context = {\n+            \n+        }\n+        \n+        \n+        record = model.record \n+        context.update(record)\n+        user = await self.app.services.user.get(uid=user_uid)\n+        context.update(dict(\n+            user_uid=user['uid'],\n+            username=user['username'],\n+            user_nick=user['nick']\n+        ))\n+        try:\n+            template = self.app.jinja2_env.get_template(\"message.html\")\n+            model[\"html\"] = template.render(**context)\n+        except Exception as ex:\n+            print(ex,flush=True)\n+        print(\"RENDER\",flush=True)\n+        print(\"RECORD\",context,flush=True)\n+        \n+        print(\"AFTER RENDER\",flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex fcbf0bc..b31f747 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,7 +17,8 @@ class ChatService(BaseService):\n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n-            message=message,\n+            message=channel_message[\"message\"],\n+            html=channel_message[\"html\"],\n             user_uid=user_uid,\n             color=user['color'],\n             channel_uid=channel_uid,\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 8c8cf7f..01fe8fb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -34,6 +34,7 @@ class MessageListElement extends HTMLElement {\n         element.dataset.created_at = message.created_at\n         element.dataset.user_uid = message.user_uid\n         element.dataset.message = message.message \n+        \n         element.classList.add(\"message\")\n         if(!this.messages.length){\n             element.classList.add(\"switch-user\")\n@@ -53,7 +54,8 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        text.innerHTML = this.linkifyText(message.message)\n+        if(message.html)\n+            text.innerHTML = this.linkifyText(message.html)\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at\n@@ -79,6 +81,7 @@ class MessageListElement extends HTMLElement {\n             message.user_nick,\n             message.color,\n             message.message,\n+            message.html,\n             message.created_at,\n             message.updated_at\n         )\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex a28262a..1c05b42 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -1,14 +1,16 @@\n class MessageModel {\n     message = null \n+    html = null\n     user_uid = null \n     channel_uid = null \n     created_at = null \n     updated_at = null  \n     element = null \n     color = null\n-    constructor(uid, channel_uid,user_uid,user_nick, color,message,created_at, updated_at){\n+    constructor(uid, channel_uid,user_uid,user_nick, color,message,html,created_at, updated_at){\n         this.uid = uid \n         this.message = message \n+        this.html = html \n         this.user_uid = user_uid \n         this.user_nick = user_nick\n         this.color = color\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex af04516..6bbfa20 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -73,7 +73,8 @@ class RPCView(BaseView):\n                     channel_uid=message[\"channel_uid\"],\n                     user_nick=user['nick'],\n                     message=message[\"message\"],\n-                    created_at=message[\"created_at\"]\n+                    created_at=message[\"created_at\"],\n+                    html=message['html'] \n                 ))\n             return messages"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added message template with styling and markdown support", "commit": "561a915e30274d8b191678135912313ebccde70f", "diff": "commit 561a915e30274d8b191678135912313ebccde70f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:31:06 2025 +0100\n\n    Message\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nnew file mode 100644\nindex 0000000..d2b8809\n--- /dev/null\n+++ b/src/snek/templates/message.html\n@@ -0,0 +1,10 @@\n+<style>\n+    {{highlight_styles}}\n+</style>\n+    <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n+        {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n+    </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project to snekbak", "commit": "d7c003c4096f8cfed8f4edd517f41d45f4f8b501", "diff": "commit d7c003c4096f8cfed8f4edd517f41d45f4f8b501\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:36:18 2025 +0100\n\n    temporary move\n\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\ndeleted file mode 100644\nindex 50a4245..0000000\n--- a/src/snek/docs/app.py\n+++ /dev/null\n@@ -1,43 +0,0 @@\n-import pathlib\n-\n-from aiohttp import web\n-from app.app import Application as BaseApplication\n-\n-from snek.system.markdown import MarkdownExtension\n-\n-\n-class Application(BaseApplication):\n-\n-    def __init__(self, path=None, *args, **kwargs):\n-        self.path = pathlib.Path(path)\n-        template_path = self.path\n-\n-        super().__init__(template_path=template_path, *args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-\n-        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n-\n-    async def handle_document(self, request):\n-        relative_path = request.match_info[\"tail\"].strip(\"/\")\n-        if relative_path == \"\":\n-            relative_path = \"index.html\"\n-        document_path = self.path.joinpath(relative_path)\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-        if document_path.is_dir():\n-            document_path = document_path.joinpath(\"index.html\")\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-\n-        response = await self.render_template(\n-            str(document_path.relative_to(self.path)), request\n-        )\n-        return response\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\ndeleted file mode 100644\nindex e30a99d..0000000\n--- a/src/snek/docs/docs/api.html\n+++ /dev/null\n@@ -1,61 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n-\n-Currently only some details about the internal API are available.\n-\n-```python\n-\n-new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n-)\n-```\n-\n-```python\n-from snek.system import security\n-\n-var1 = security.encrypt(\"data\")\n-var2 = security.encrypt(b\"data\")\n-\n-assert(var1 == var2)\n-```\n-\n-```python\n-from snek.system.view import BaseView \n-\n-class IndexView(BaseView):\n-    \n-    async def get(self):\n-        return await self.render(\"index.html\")\n-```\n-```python\n-from snek.system.view import BaseFormView\n-from snek.form.register import RegisterForm\n-\n-class RegisterFormView(BaseFormView):\n-    \n-    form = RegisterForm\n-```\n-```python\n-app.routes.add_view(\"/your-page.html\", YourViewClass)\n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\ndeleted file mode 100644\nindex 7c53bec..0000000\n--- a/src/snek/docs/docs/base.html\n+++ /dev/null\n@@ -1,116 +0,0 @@\n-<html>\n-\n-<head>\n-    <style>{{ highlight_styles }}</style>\n-    <style>\n-        \n-        * {\n-\n-            box-sizing: border-box;\n-        }\n-\n-        .dialog {\n-\n-            border-radius: 10px;\n-            padding: 30px;\n-            width: 800px;\n-            margin: 30px;\n-            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n-        }\n-\n-        @media screen and (max-width: 500px) {\n-            .center {\n-                width: 100%;\n-                left: 0px;\n-            }\n-\n-            .dialog {\n-                width: 100%;\n-                left: 0px;\n-            }\n-\n-        }\n-\n-        h1 {\n-            font-size: 2em;\n-            margin-bottom: 20px;\n-        }\n-\n-        h2 {\n-            font-size: 1.4em;\n-            margin-bottom: 20px;\n-        }\n-\n-        html,body,main {\n-            font-family: Arial, sans-serif;\n-            line-height: 1.5;\n-            display: flex;\n-            flex-direction: column;\n-            align-items: center;\n-            min-height: 100vh;\n-            width: 100%;\n-        }\n-        article {\n-            max-width: 100%;\n-            width: 60%;\n-            padding: 30px;\n-            min-height: 100vh;\n-            word-break: break-all;\n-        }\n-        footer {\n-            position: fixed;\n-            width: 60%;\n-            text-align: center; \n-            bottom: 0;\n-            left: 20%;\n-        }\n-        a {\n-    display: block;\n-    margin-top: 15px;\n-    font-size: 0.9em;\n-    transition: color 0.3s;\n-  }\n-  header {\n-\n-    text-align: left;\n-    width: 60%;\n-    padding: 30px;\n-  }\n-  header a {\n-    display: inline;\n-\n-  }\n-        div {\n-            text-align: left;\n-\n-        }\n-    </style>\n-</head>\n-\n-<body>\n-    <main>\n-        <header>\n-            <a href=\"/\">Snek</a>\n-            <a href=\"/docs/docs\">Docs</a>\n-        </header>\n-      <article>\n-            {% block main %}\n-            {% endblock %}\n-        </article>\n-    </main>\n-    <footer>\n-        {% markdown %}\n-        {% endmarkdown %}\n-    </footer>\n-</body>\n-\n-</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\ndeleted file mode 100644\nindex c83ee7f..0000000\n--- a/src/snek/docs/docs/form_api_javascript.html\n+++ /dev/null\n@@ -1,17 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n- - generic-form.js \n-\n-It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n-```html \n-<generic-form url=\"/url-to-form-api\"></generic-form>\n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\ndeleted file mode 100644\nindex d7e67bc..0000000\n--- a/src/snek/docs/docs/form_api_python.html\n+++ /dev/null\n@@ -1,92 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n- - `snek.system.form.Form`\n- - `snek.system.form.HTMLElement`\n- - `snek.system.form.FormInputElement`\n- - `snek.system.form.FormButtonElement`\n-\n-Here is an example with custom validation. \n-This example contains a field that checks if user already exists. \n-If invalid, it adds an error message which automatically invalidates the field. \n-Handling of the error messages will automatically done client side.\n-\n-Forms are usaly located in `snek/form/[form name].py`.\n-\n-```python\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n-\n-class UsernameField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.value and await self.app.services.user.count(username=self.value):\n-            result.append(\"Username is not available.\")\n-        return result\n-\n-class RegisterForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Register\")\n-\n-    username = UsernameField(\n-        name=\"username\", \n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\"\n-    )\n-    email = FormInputElement(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        type=\"email\"\n-    )\n-    password = FormInputElement(\n-        name=\"password\",\n-        required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n-        type=\"password\",\n-        place_holder=\"Password\"\n-    )\n-    action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Register\",\n-        type=\"button\"\n-    )\n-```\n-\n-\n-```python \n-data = dict(\n-    username=dict(value=\"retoor\"),\n-    password=dict(value=\"retoorded\")\n-)\n-form.set_user_data(data)\n-\n-is_valid = await form.is_valid\n-\n-key_value_values = await form.record \n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\ndeleted file mode 100644\nindex 8ced8ab..0000000\n--- a/src/snek/docs/docs/index.html\n+++ /dev/null\n@@ -1,37 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n-\n-Snek is a high customizable chat application. \n-It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n-\n-This documentation is under construction. Only the form API and the small introduction is a bit documented.\n-\n-[Small introduction / cheatsheet](/docs/docs/api.html)\n-\n-With the view classes of Snek you can render HTML and Markdown \n-\n-Snek's database model is based on Python dataset library. \n-Snek uses a model/mapper architecture build on top of that library.\n-\n-Snek does have his own components for creating and rendering forms. \n-All forms are made server side and client side is generated client side using a HTML component.\n-It's client side only one line to include a form that can validate and submit.\n-Validation is server side using REST. Page won't refresh.\n-[API Python](/docs/docs/form_api_python.html) \n-[API Javascript](/docs/docs/form_api_javascript.html)\n-\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snekbak/app.py\nsimilarity index 100%\nrename from src/snek/app.py\nrename to src/snekbak/app.py\ndiff --git a/src/snek/form/__init__.py b/src/snekbak/form/__init__.py\nsimilarity index 100%\nrename from src/snek/form/__init__.py\nrename to src/snekbak/form/__init__.py\ndiff --git a/src/snek/form/login.py b/src/snekbak/form/login.py\nsimilarity index 100%\nrename from src/snek/form/login.py\nrename to src/snekbak/form/login.py\ndiff --git a/src/snek/form/register.py b/src/snekbak/form/register.py\nsimilarity index 100%\nrename from src/snek/form/register.py\nrename to src/snekbak/form/register.py\ndiff --git a/src/snek/gunicorn.py b/src/snekbak/gunicorn.py\nsimilarity index 100%\nrename from src/snek/gunicorn.py\nrename to src/snekbak/gunicorn.py\ndiff --git a/src/snek/mapper/__init__.py b/src/snekbak/mapper/__init__.py\nsimilarity index 100%\nrename from src/snek/mapper/__init__.py\nrename to src/snekbak/mapper/__init__.py\ndiff --git a/src/snek/mapper/channel.py b/src/snekbak/mapper/channel.py\nsimilarity index 100%\nrename from src/snek/mapper/channel.py\nrename to src/snekbak/mapper/channel.py\ndiff --git a/src/snek/mapper/channel_member.py b/src/snekbak/mapper/channel_member.py\nsimilarity index 100%\nrename from src/snek/mapper/channel_member.py\nrename to src/snekbak/mapper/channel_member.py\ndiff --git a/src/snek/mapper/channel_message.py b/src/snekbak/mapper/channel_message.py\nsimilarity index 100%\nrename from src/snek/mapper/channel_message.py\nrename to src/snekbak/mapper/channel_message.py\ndiff --git a/src/snek/mapper/notification.py b/src/snekbak/mapper/notification.py\nsimilarity index 100%\nrename from src/snek/mapper/notification.py\nrename to src/snekbak/mapper/notification.py\ndiff --git a/src/snek/mapper/user.py b/src/snekbak/mapper/user.py\nsimilarity index 100%\nrename from src/snek/mapper/user.py\nrename to src/snekbak/mapper/user.py\ndiff --git a/src/snek/model/__init__.py b/src/snekbak/model/__init__.py\nsimilarity index 100%\nrename from src/snek/model/__init__.py\nrename to src/snekbak/model/__init__.py\ndiff --git a/src/snek/model/channel.py b/src/snekbak/model/channel.py\nsimilarity index 100%\nrename from src/snek/model/channel.py\nrename to src/snekbak/model/channel.py\ndiff --git a/src/snek/model/channel_member.py b/src/snekbak/model/channel_member.py\nsimilarity index 100%\nrename from src/snek/model/channel_member.py\nrename to src/snekbak/model/channel_member.py\ndiff --git a/src/snek/model/channel_message.py b/src/snekbak/model/channel_message.py\nsimilarity index 100%\nrename from src/snek/model/channel_message.py\nrename to src/snekbak/model/channel_message.py\ndiff --git a/src/snek/model/notification.py b/src/snekbak/model/notification.py\nsimilarity index 100%\nrename from src/snek/model/notification.py\nrename to src/snekbak/model/notification.py\ndiff --git a/src/snek/model/user.py b/src/snekbak/model/user.py\nsimilarity index 100%\nrename from src/snek/model/user.py\nrename to src/snekbak/model/user.py\ndiff --git a/src/snek/service/__init__.py b/src/snekbak/service/__init__.py\nsimilarity index 100%\nrename from src/snek/service/__init__.py\nrename to src/snekbak/service/__init__.py\ndiff --git a/src/snek/service/channel.py b/src/snekbak/service/channel.py\nsimilarity index 100%\nrename from src/snek/service/channel.py\nrename to src/snekbak/service/channel.py\ndiff --git a/src/snek/service/channel_member.py b/src/snekbak/service/channel_member.py\nsimilarity index 100%\nrename from src/snek/service/channel_member.py\nrename to src/snekbak/service/channel_member.py\ndiff --git a/src/snek/service/channel_message.py b/src/snekbak/service/channel_message.py\nsimilarity index 100%\nrename from src/snek/service/channel_message.py\nrename to src/snekbak/service/channel_message.py\ndiff --git a/src/snek/service/chat.py b/src/snekbak/service/chat.py\nsimilarity index 100%\nrename from src/snek/service/chat.py\nrename to src/snekbak/service/chat.py\ndiff --git a/src/snek/service/notification.py b/src/snekbak/service/notification.py\nsimilarity index 100%\nrename from src/snek/service/notification.py\nrename to src/snekbak/service/notification.py\ndiff --git a/src/snek/service/socket.py b/src/snekbak/service/socket.py\nsimilarity index 100%\nrename from src/snek/service/socket.py\nrename to src/snekbak/service/socket.py\ndiff --git a/src/snek/service/user.py b/src/snekbak/service/user.py\nsimilarity index 100%\nrename from src/snek/service/user.py\nrename to src/snekbak/service/user.py\ndiff --git a/src/snek/service/util.py b/src/snekbak/service/util.py\nsimilarity index 100%\nrename from src/snek/service/util.py\nrename to src/snekbak/service/util.py\ndiff --git a/src/snek/static/app.js b/src/snekbak/static/app.js\nsimilarity index 100%\nrename from src/snek/static/app.js\nrename to src/snekbak/static/app.js\ndiff --git a/src/snek/static/audio/soundfx.d_alarm1.mp3 b/src/snekbak/static/audio/soundfx.d_alarm1.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_alarm1.mp3\nrename to src/snekbak/static/audio/soundfx.d_alarm1.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_alarm2.mp3 b/src/snekbak/static/audio/soundfx.d_alarm2.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_alarm2.mp3\nrename to src/snekbak/static/audio/soundfx.d_alarm2.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep1.mp3 b/src/snekbak/static/audio/soundfx.d_beep1.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep1.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep1.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep2.mp3 b/src/snekbak/static/audio/soundfx.d_beep2.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep2.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep2.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep3.mp3 b/src/snekbak/static/audio/soundfx.d_beep3.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep3.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep3.mp3\ndiff --git a/src/snek/static/base.css b/src/snekbak/static/base.css\nsimilarity index 100%\nrename from src/snek/static/base.css\nrename to src/snekbak/static/base.css\ndiff --git a/src/snek/static/chat-input.js b/src/snekbak/static/chat-input.js\nsimilarity index 100%\nrename from src/snek/static/chat-input.js\nrename to src/snekbak/static/chat-input.js\ndiff --git a/src/snek/static/chat-window.js b/src/snekbak/static/chat-window.js\nsimilarity index 100%\nrename from src/snek/static/chat-window.js\nrename to src/snekbak/static/chat-window.js\ndiff --git a/src/snek/static/fancy-button.js b/src/snekbak/static/fancy-button.js\nsimilarity index 100%\nrename from src/snek/static/fancy-button.js\nrename to src/snekbak/static/fancy-button.js\ndiff --git a/src/snek/static/generic-form.css b/src/snekbak/static/generic-form.css\nsimilarity index 100%\nrename from src/snek/static/generic-form.css\nrename to src/snekbak/static/generic-form.css\ndiff --git a/src/snek/static/generic-form.js b/src/snekbak/static/generic-form.js\nsimilarity index 100%\nrename from src/snek/static/generic-form.js\nrename to src/snekbak/static/generic-form.js\ndiff --git a/src/snek/static/html-frame.css b/src/snekbak/static/html-frame.css\nsimilarity index 100%\nrename from src/snek/static/html-frame.css\nrename to src/snekbak/static/html-frame.css\ndiff --git a/src/snek/static/html-frame.js b/src/snekbak/static/html-frame.js\nsimilarity index 100%\nrename from src/snek/static/html-frame.js\nrename to src/snekbak/static/html-frame.js\ndiff --git a/src/snek/static/manifest.json b/src/snekbak/static/manifest.json\nsimilarity index 100%\nrename from src/snek/static/manifest.json\nrename to src/snekbak/static/manifest.json\ndiff --git a/src/snek/static/markdown-frame.js b/src/snekbak/static/markdown-frame.js\nsimilarity index 100%\nrename from src/snek/static/markdown-frame.js\nrename to src/snekbak/static/markdown-frame.js\ndiff --git a/src/snek/static/message-list-manager.js b/src/snekbak/static/message-list-manager.js\nsimilarity index 100%\nrename from src/snek/static/message-list-manager.js\nrename to src/snekbak/static/message-list-manager.js\ndiff --git a/src/snek/static/message-list.js b/src/snekbak/static/message-list.js\nsimilarity index 100%\nrename from src/snek/static/message-list.js\nrename to src/snekbak/static/message-list.js\ndiff --git a/src/snek/static/models.js b/src/snekbak/static/models.js\nsimilarity index 100%\nrename from src/snek/static/models.js\nrename to src/snekbak/static/models.js\ndiff --git a/src/snek/static/register__.css b/src/snekbak/static/register__.css\nsimilarity index 100%\nrename from src/snek/static/register__.css\nrename to src/snekbak/static/register__.css\ndiff --git a/src/snek/static/schedule.js b/src/snekbak/static/schedule.js\nsimilarity index 100%\nrename from src/snek/static/schedule.js\nrename to src/snekbak/static/schedule.js\ndiff --git a/src/snek/static/style.css b/src/snekbak/static/style.css\nsimilarity index 100%\nrename from src/snek/static/style.css\nrename to src/snekbak/static/style.css\ndiff --git a/src/snek/system/__init__.py b/src/snekbak/system/__init__.py\nsimilarity index 100%\nrename from src/snek/system/__init__.py\nrename to src/snekbak/system/__init__.py\ndiff --git a/src/snek/system/api.py b/src/snekbak/system/api.py\nsimilarity index 100%\nrename from src/snek/system/api.py\nrename to src/snekbak/system/api.py\ndiff --git a/src/snek/system/cache.py b/src/snekbak/system/cache.py\nsimilarity index 100%\nrename from src/snek/system/cache.py\nrename to src/snekbak/system/cache.py\ndiff --git a/src/snek/system/form.py b/src/snekbak/system/form.py\nsimilarity index 100%\nrename from src/snek/system/form.py\nrename to src/snekbak/system/form.py\ndiff --git a/src/snek/system/http.py b/src/snekbak/system/http.py\nsimilarity index 100%\nrename from src/snek/system/http.py\nrename to src/snekbak/system/http.py\ndiff --git a/src/snek/system/mapper.py b/src/snekbak/system/mapper.py\nsimilarity index 100%\nrename from src/snek/system/mapper.py\nrename to src/snekbak/system/mapper.py\ndiff --git a/src/snek/system/markdown.py b/src/snekbak/system/markdown.py\nsimilarity index 100%\nrename from src/snek/system/markdown.py\nrename to src/snekbak/system/markdown.py\ndiff --git a/src/snek/system/middleware.py b/src/snekbak/system/middleware.py\nsimilarity index 100%\nrename from src/snek/system/middleware.py\nrename to src/snekbak/system/middleware.py\ndiff --git a/src/snek/system/model.py b/src/snekbak/system/model.py\nsimilarity index 100%\nrename from src/snek/system/model.py\nrename to src/snekbak/system/model.py\ndiff --git a/src/snek/system/object.py b/src/snekbak/system/object.py\nsimilarity index 100%\nrename from src/snek/system/object.py\nrename to src/snekbak/system/object.py\ndiff --git a/src/snek/system/security.py b/src/snekbak/system/security.py\nsimilarity index 100%\nrename from src/snek/system/security.py\nrename to src/snekbak/system/security.py\ndiff --git a/src/snek/system/service.py b/src/snekbak/system/service.py\nsimilarity index 100%\nrename from src/snek/system/service.py\nrename to src/snekbak/system/service.py\ndiff --git a/src/snek/system/view.py b/src/snekbak/system/view.py\nsimilarity index 100%\nrename from src/snek/system/view.py\nrename to src/snekbak/system/view.py\ndiff --git a/src/snek/templates/about.html b/src/snekbak/templates/about.html\nsimilarity index 100%\nrename from src/snek/templates/about.html\nrename to src/snekbak/templates/about.html\ndiff --git a/src/snek/templates/about.md b/src/snekbak/templates/about.md\nsimilarity index 100%\nrename from src/snek/templates/about.md\nrename to src/snekbak/templates/about.md\ndiff --git a/src/snek/templates/base.html b/src/snekbak/templates/base.html\nsimilarity index 100%\nrename from src/snek/templates/base.html\nrename to src/snekbak/templates/base.html\ndiff --git a/src/snek/templates/base_chat.html b/src/snekbak/templates/base_chat.html\nsimilarity index 100%\nrename from src/snek/templates/base_chat.html\nrename to src/snekbak/templates/base_chat.html\ndiff --git a/src/snek/templates/docs.html b/src/snekbak/templates/docs.html\nsimilarity index 100%\nrename from src/snek/templates/docs.html\nrename to src/snekbak/templates/docs.html\ndiff --git a/src/snek/templates/docs.md b/src/snekbak/templates/docs.md\nsimilarity index 100%\nrename from src/snek/templates/docs.md\nrename to src/snekbak/templates/docs.md\ndiff --git a/src/snek/templates/index.html b/src/snekbak/templates/index.html\nsimilarity index 100%\nrename from src/snek/templates/index.html\nrename to src/snekbak/templates/index.html\ndiff --git a/src/snek/templates/login.html b/src/snekbak/templates/login.html\nsimilarity index 100%\nrename from src/snek/templates/login.html\nrename to src/snekbak/templates/login.html\ndiff --git a/src/snek/templates/message.html b/src/snekbak/templates/message.html\nsimilarity index 100%\nrename from src/snek/templates/message.html\nrename to src/snekbak/templates/message.html\ndiff --git a/src/snek/templates/register.html b/src/snekbak/templates/register.html\nsimilarity index 100%\nrename from src/snek/templates/register.html\nrename to src/snekbak/templates/register.html\ndiff --git a/src/snek/templates/test2.html b/src/snekbak/templates/test2.html\nsimilarity index 100%\nrename from src/snek/templates/test2.html\nrename to src/snekbak/templates/test2.html\ndiff --git a/src/snek/templates/web.html b/src/snekbak/templates/web.html\nsimilarity index 100%\nrename from src/snek/templates/web.html\nrename to src/snekbak/templates/web.html\ndiff --git a/src/snek/view/__init__.py b/src/snekbak/view/__init__.py\nsimilarity index 100%\nrename from src/snek/view/__init__.py\nrename to src/snekbak/view/__init__.py\ndiff --git a/src/snek/view/about.py b/src/snekbak/view/about.py\nsimilarity index 100%\nrename from src/snek/view/about.py\nrename to src/snekbak/view/about.py\ndiff --git a/src/snek/view/docs.py b/src/snekbak/view/docs.py\nsimilarity index 100%\nrename from src/snek/view/docs.py\nrename to src/snekbak/view/docs.py\ndiff --git a/src/snek/view/index.py b/src/snekbak/view/index.py\nsimilarity index 100%\nrename from src/snek/view/index.py\nrename to src/snekbak/view/index.py\ndiff --git a/src/snek/view/login.py b/src/snekbak/view/login.py\nsimilarity index 100%\nrename from src/snek/view/login.py\nrename to src/snekbak/view/login.py\ndiff --git a/src/snek/view/login_form.py b/src/snekbak/view/login_form.py\nsimilarity index 100%\nrename from src/snek/view/login_form.py\nrename to src/snekbak/view/login_form.py\ndiff --git a/src/snek/view/logout.py b/src/snekbak/view/logout.py\nsimilarity index 100%\nrename from src/snek/view/logout.py\nrename to src/snekbak/view/logout.py\ndiff --git a/src/snek/view/register.py b/src/snekbak/view/register.py\nsimilarity index 100%\nrename from src/snek/view/register.py\nrename to src/snekbak/view/register.py\ndiff --git a/src/snek/view/register_form.py b/src/snekbak/view/register_form.py\nsimilarity index 100%\nrename from src/snek/view/register_form.py\nrename to src/snekbak/view/register_form.py\ndiff --git a/src/snek/view/rpc.py b/src/snekbak/view/rpc.py\nsimilarity index 100%\nrename from src/snek/view/rpc.py\nrename to src/snekbak/view/rpc.py\ndiff --git a/src/snek/view/status.py b/src/snekbak/view/status.py\nsimilarity index 100%\nrename from src/snek/view/status.py\nrename to src/snekbak/view/status.py\ndiff --git a/src/snek/view/web.py b/src/snekbak/view/web.py\nsimilarity index 100%\nrename from src/snek/view/web.py\nrename to src/snekbak/view/web.py"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project from snekbak to snek", "commit": "f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98", "diff": "commit f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:37:16 2025 +0100\n\n    Move back\n\ndiff --git a/src/snekbak/app.py b/src/snek/app.py\nsimilarity index 100%\nrename from src/snekbak/app.py\nrename to src/snek/app.py\ndiff --git a/src/snekbak/form/__init__.py b/src/snek/form/__init__.py\nsimilarity index 100%\nrename from src/snekbak/form/__init__.py\nrename to src/snek/form/__init__.py\ndiff --git a/src/snekbak/form/login.py b/src/snek/form/login.py\nsimilarity index 100%\nrename from src/snekbak/form/login.py\nrename to src/snek/form/login.py\ndiff --git a/src/snekbak/form/register.py b/src/snek/form/register.py\nsimilarity index 100%\nrename from src/snekbak/form/register.py\nrename to src/snek/form/register.py\ndiff --git a/src/snekbak/gunicorn.py b/src/snek/gunicorn.py\nsimilarity index 100%\nrename from src/snekbak/gunicorn.py\nrename to src/snek/gunicorn.py\ndiff --git a/src/snekbak/mapper/__init__.py b/src/snek/mapper/__init__.py\nsimilarity index 100%\nrename from src/snekbak/mapper/__init__.py\nrename to src/snek/mapper/__init__.py\ndiff --git a/src/snekbak/mapper/channel.py b/src/snek/mapper/channel.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel.py\nrename to src/snek/mapper/channel.py\ndiff --git a/src/snekbak/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel_member.py\nrename to src/snek/mapper/channel_member.py\ndiff --git a/src/snekbak/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel_message.py\nrename to src/snek/mapper/channel_message.py\ndiff --git a/src/snekbak/mapper/notification.py b/src/snek/mapper/notification.py\nsimilarity index 100%\nrename from src/snekbak/mapper/notification.py\nrename to src/snek/mapper/notification.py\ndiff --git a/src/snekbak/mapper/user.py b/src/snek/mapper/user.py\nsimilarity index 100%\nrename from src/snekbak/mapper/user.py\nrename to src/snek/mapper/user.py\ndiff --git a/src/snekbak/model/__init__.py b/src/snek/model/__init__.py\nsimilarity index 100%\nrename from src/snekbak/model/__init__.py\nrename to src/snek/model/__init__.py\ndiff --git a/src/snekbak/model/channel.py b/src/snek/model/channel.py\nsimilarity index 100%\nrename from src/snekbak/model/channel.py\nrename to src/snek/model/channel.py\ndiff --git a/src/snekbak/model/channel_member.py b/src/snek/model/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/model/channel_member.py\nrename to src/snek/model/channel_member.py\ndiff --git a/src/snekbak/model/channel_message.py b/src/snek/model/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/model/channel_message.py\nrename to src/snek/model/channel_message.py\ndiff --git a/src/snekbak/model/notification.py b/src/snek/model/notification.py\nsimilarity index 100%\nrename from src/snekbak/model/notification.py\nrename to src/snek/model/notification.py\ndiff --git a/src/snekbak/model/user.py b/src/snek/model/user.py\nsimilarity index 100%\nrename from src/snekbak/model/user.py\nrename to src/snek/model/user.py\ndiff --git a/src/snekbak/service/__init__.py b/src/snek/service/__init__.py\nsimilarity index 100%\nrename from src/snekbak/service/__init__.py\nrename to src/snek/service/__init__.py\ndiff --git a/src/snekbak/service/channel.py b/src/snek/service/channel.py\nsimilarity index 100%\nrename from src/snekbak/service/channel.py\nrename to src/snek/service/channel.py\ndiff --git a/src/snekbak/service/channel_member.py b/src/snek/service/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/service/channel_member.py\nrename to src/snek/service/channel_member.py\ndiff --git a/src/snekbak/service/channel_message.py b/src/snek/service/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/service/channel_message.py\nrename to src/snek/service/channel_message.py\ndiff --git a/src/snekbak/service/chat.py b/src/snek/service/chat.py\nsimilarity index 100%\nrename from src/snekbak/service/chat.py\nrename to src/snek/service/chat.py\ndiff --git a/src/snekbak/service/notification.py b/src/snek/service/notification.py\nsimilarity index 100%\nrename from src/snekbak/service/notification.py\nrename to src/snek/service/notification.py\ndiff --git a/src/snekbak/service/socket.py b/src/snek/service/socket.py\nsimilarity index 100%\nrename from src/snekbak/service/socket.py\nrename to src/snek/service/socket.py\ndiff --git a/src/snekbak/service/user.py b/src/snek/service/user.py\nsimilarity index 100%\nrename from src/snekbak/service/user.py\nrename to src/snek/service/user.py\ndiff --git a/src/snekbak/service/util.py b/src/snek/service/util.py\nsimilarity index 100%\nrename from src/snekbak/service/util.py\nrename to src/snek/service/util.py\ndiff --git a/src/snekbak/static/app.js b/src/snek/static/app.js\nsimilarity index 100%\nrename from src/snekbak/static/app.js\nrename to src/snek/static/app.js\ndiff --git a/src/snekbak/static/audio/soundfx.d_alarm1.mp3 b/src/snek/static/audio/soundfx.d_alarm1.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_alarm1.mp3\nrename to src/snek/static/audio/soundfx.d_alarm1.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_alarm2.mp3 b/src/snek/static/audio/soundfx.d_alarm2.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_alarm2.mp3\nrename to src/snek/static/audio/soundfx.d_alarm2.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep1.mp3 b/src/snek/static/audio/soundfx.d_beep1.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep1.mp3\nrename to src/snek/static/audio/soundfx.d_beep1.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep2.mp3 b/src/snek/static/audio/soundfx.d_beep2.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep2.mp3\nrename to src/snek/static/audio/soundfx.d_beep2.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep3.mp3 b/src/snek/static/audio/soundfx.d_beep3.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep3.mp3\nrename to src/snek/static/audio/soundfx.d_beep3.mp3\ndiff --git a/src/snekbak/static/base.css b/src/snek/static/base.css\nsimilarity index 100%\nrename from src/snekbak/static/base.css\nrename to src/snek/static/base.css\ndiff --git a/src/snekbak/static/chat-input.js b/src/snek/static/chat-input.js\nsimilarity index 100%\nrename from src/snekbak/static/chat-input.js\nrename to src/snek/static/chat-input.js\ndiff --git a/src/snekbak/static/chat-window.js b/src/snek/static/chat-window.js\nsimilarity index 100%\nrename from src/snekbak/static/chat-window.js\nrename to src/snek/static/chat-window.js\ndiff --git a/src/snekbak/static/fancy-button.js b/src/snek/static/fancy-button.js\nsimilarity index 100%\nrename from src/snekbak/static/fancy-button.js\nrename to src/snek/static/fancy-button.js\ndiff --git a/src/snekbak/static/generic-form.css b/src/snek/static/generic-form.css\nsimilarity index 100%\nrename from src/snekbak/static/generic-form.css\nrename to src/snek/static/generic-form.css\ndiff --git a/src/snekbak/static/generic-form.js b/src/snek/static/generic-form.js\nsimilarity index 100%\nrename from src/snekbak/static/generic-form.js\nrename to src/snek/static/generic-form.js\ndiff --git a/src/snekbak/static/html-frame.css b/src/snek/static/html-frame.css\nsimilarity index 100%\nrename from src/snekbak/static/html-frame.css\nrename to src/snek/static/html-frame.css\ndiff --git a/src/snekbak/static/html-frame.js b/src/snek/static/html-frame.js\nsimilarity index 100%\nrename from src/snekbak/static/html-frame.js\nrename to src/snek/static/html-frame.js\ndiff --git a/src/snekbak/static/manifest.json b/src/snek/static/manifest.json\nsimilarity index 100%\nrename from src/snekbak/static/manifest.json\nrename to src/snek/static/manifest.json\ndiff --git a/src/snekbak/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nsimilarity index 100%\nrename from src/snekbak/static/markdown-frame.js\nrename to src/snek/static/markdown-frame.js\ndiff --git a/src/snekbak/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nsimilarity index 100%\nrename from src/snekbak/static/message-list-manager.js\nrename to src/snek/static/message-list-manager.js\ndiff --git a/src/snekbak/static/message-list.js b/src/snek/static/message-list.js\nsimilarity index 100%\nrename from src/snekbak/static/message-list.js\nrename to src/snek/static/message-list.js\ndiff --git a/src/snekbak/static/models.js b/src/snek/static/models.js\nsimilarity index 100%\nrename from src/snekbak/static/models.js\nrename to src/snek/static/models.js\ndiff --git a/src/snekbak/static/register__.css b/src/snek/static/register__.css\nsimilarity index 100%\nrename from src/snekbak/static/register__.css\nrename to src/snek/static/register__.css\ndiff --git a/src/snekbak/static/schedule.js b/src/snek/static/schedule.js\nsimilarity index 100%\nrename from src/snekbak/static/schedule.js\nrename to src/snek/static/schedule.js\ndiff --git a/src/snekbak/static/style.css b/src/snek/static/style.css\nsimilarity index 100%\nrename from src/snekbak/static/style.css\nrename to src/snek/static/style.css\ndiff --git a/src/snekbak/system/__init__.py b/src/snek/system/__init__.py\nsimilarity index 100%\nrename from src/snekbak/system/__init__.py\nrename to src/snek/system/__init__.py\ndiff --git a/src/snekbak/system/api.py b/src/snek/system/api.py\nsimilarity index 100%\nrename from src/snekbak/system/api.py\nrename to src/snek/system/api.py\ndiff --git a/src/snekbak/system/cache.py b/src/snek/system/cache.py\nsimilarity index 100%\nrename from src/snekbak/system/cache.py\nrename to src/snek/system/cache.py\ndiff --git a/src/snekbak/system/form.py b/src/snek/system/form.py\nsimilarity index 100%\nrename from src/snekbak/system/form.py\nrename to src/snek/system/form.py\ndiff --git a/src/snekbak/system/http.py b/src/snek/system/http.py\nsimilarity index 100%\nrename from src/snekbak/system/http.py\nrename to src/snek/system/http.py\ndiff --git a/src/snekbak/system/mapper.py b/src/snek/system/mapper.py\nsimilarity index 100%\nrename from src/snekbak/system/mapper.py\nrename to src/snek/system/mapper.py\ndiff --git a/src/snekbak/system/markdown.py b/src/snek/system/markdown.py\nsimilarity index 100%\nrename from src/snekbak/system/markdown.py\nrename to src/snek/system/markdown.py\ndiff --git a/src/snekbak/system/middleware.py b/src/snek/system/middleware.py\nsimilarity index 100%\nrename from src/snekbak/system/middleware.py\nrename to src/snek/system/middleware.py\ndiff --git a/src/snekbak/system/model.py b/src/snek/system/model.py\nsimilarity index 100%\nrename from src/snekbak/system/model.py\nrename to src/snek/system/model.py\ndiff --git a/src/snekbak/system/object.py b/src/snek/system/object.py\nsimilarity index 100%\nrename from src/snekbak/system/object.py\nrename to src/snek/system/object.py\ndiff --git a/src/snekbak/system/security.py b/src/snek/system/security.py\nsimilarity index 100%\nrename from src/snekbak/system/security.py\nrename to src/snek/system/security.py\ndiff --git a/src/snekbak/system/service.py b/src/snek/system/service.py\nsimilarity index 100%\nrename from src/snekbak/system/service.py\nrename to src/snek/system/service.py\ndiff --git a/src/snekbak/system/view.py b/src/snek/system/view.py\nsimilarity index 100%\nrename from src/snekbak/system/view.py\nrename to src/snek/system/view.py\ndiff --git a/src/snekbak/templates/about.html b/src/snek/templates/about.html\nsimilarity index 100%\nrename from src/snekbak/templates/about.html\nrename to src/snek/templates/about.html\ndiff --git a/src/snekbak/templates/about.md b/src/snek/templates/about.md\nsimilarity index 100%\nrename from src/snekbak/templates/about.md\nrename to src/snek/templates/about.md\ndiff --git a/src/snekbak/templates/base.html b/src/snek/templates/base.html\nsimilarity index 100%\nrename from src/snekbak/templates/base.html\nrename to src/snek/templates/base.html\ndiff --git a/src/snekbak/templates/base_chat.html b/src/snek/templates/base_chat.html\nsimilarity index 100%\nrename from src/snekbak/templates/base_chat.html\nrename to src/snek/templates/base_chat.html\ndiff --git a/src/snekbak/templates/docs.html b/src/snek/templates/docs.html\nsimilarity index 100%\nrename from src/snekbak/templates/docs.html\nrename to src/snek/templates/docs.html\ndiff --git a/src/snekbak/templates/docs.md b/src/snek/templates/docs.md\nsimilarity index 100%\nrename from src/snekbak/templates/docs.md\nrename to src/snek/templates/docs.md\ndiff --git a/src/snekbak/templates/index.html b/src/snek/templates/index.html\nsimilarity index 100%\nrename from src/snekbak/templates/index.html\nrename to src/snek/templates/index.html\ndiff --git a/src/snekbak/templates/login.html b/src/snek/templates/login.html\nsimilarity index 100%\nrename from src/snekbak/templates/login.html\nrename to src/snek/templates/login.html\ndiff --git a/src/snekbak/templates/message.html b/src/snek/templates/message.html\nsimilarity index 100%\nrename from src/snekbak/templates/message.html\nrename to src/snek/templates/message.html\ndiff --git a/src/snekbak/templates/register.html b/src/snek/templates/register.html\nsimilarity index 100%\nrename from src/snekbak/templates/register.html\nrename to src/snek/templates/register.html\ndiff --git a/src/snekbak/templates/test2.html b/src/snek/templates/test2.html\nsimilarity index 100%\nrename from src/snekbak/templates/test2.html\nrename to src/snek/templates/test2.html\ndiff --git a/src/snekbak/templates/web.html b/src/snek/templates/web.html\nsimilarity index 100%\nrename from src/snekbak/templates/web.html\nrename to src/snek/templates/web.html\ndiff --git a/src/snekbak/view/__init__.py b/src/snek/view/__init__.py\nsimilarity index 100%\nrename from src/snekbak/view/__init__.py\nrename to src/snek/view/__init__.py\ndiff --git a/src/snekbak/view/about.py b/src/snek/view/about.py\nsimilarity index 100%\nrename from src/snekbak/view/about.py\nrename to src/snek/view/about.py\ndiff --git a/src/snekbak/view/docs.py b/src/snek/view/docs.py\nsimilarity index 100%\nrename from src/snekbak/view/docs.py\nrename to src/snek/view/docs.py\ndiff --git a/src/snekbak/view/index.py b/src/snek/view/index.py\nsimilarity index 100%\nrename from src/snekbak/view/index.py\nrename to src/snek/view/index.py\ndiff --git a/src/snekbak/view/login.py b/src/snek/view/login.py\nsimilarity index 100%\nrename from src/snekbak/view/login.py\nrename to src/snek/view/login.py\ndiff --git a/src/snekbak/view/login_form.py b/src/snek/view/login_form.py\nsimilarity index 100%\nrename from src/snekbak/view/login_form.py\nrename to src/snek/view/login_form.py\ndiff --git a/src/snekbak/view/logout.py b/src/snek/view/logout.py\nsimilarity index 100%\nrename from src/snekbak/view/logout.py\nrename to src/snek/view/logout.py\ndiff --git a/src/snekbak/view/register.py b/src/snek/view/register.py\nsimilarity index 100%\nrename from src/snekbak/view/register.py\nrename to src/snek/view/register.py\ndiff --git a/src/snekbak/view/register_form.py b/src/snek/view/register_form.py\nsimilarity index 100%\nrename from src/snekbak/view/register_form.py\nrename to src/snek/view/register_form.py\ndiff --git a/src/snekbak/view/rpc.py b/src/snek/view/rpc.py\nsimilarity index 100%\nrename from src/snekbak/view/rpc.py\nrename to src/snek/view/rpc.py\ndiff --git a/src/snekbak/view/status.py b/src/snek/view/status.py\nsimilarity index 100%\nrename from src/snekbak/view/status.py\nrename to src/snek/view/status.py\ndiff --git a/src/snekbak/view/web.py b/src/snek/view/web.py\nsimilarity index 100%\nrename from src/snekbak/view/web.py\nrename to src/snek/view/web.py"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added basic documentation site with API and form examples", "commit": "b562d171674c2f75592ff3a0dd25b51d2a2457db", "diff": "commit b562d171674c2f75592ff3a0dd25b51d2a2457db\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:41:34 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nnew file mode 100644\nindex 0000000..6f355a6\nBinary files /dev/null and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nnew file mode 100644\nindex 0000000..50a4245\n--- /dev/null\n+++ b/src/snek/docs/app.py\n@@ -0,0 +1,43 @@\n+import pathlib\n+\n+from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n+from snek.system.markdown import MarkdownExtension\n+\n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, path=None, *args, **kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+\n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\nnew file mode 100644\nindex 0000000..e30a99d\n--- /dev/null\n+++ b/src/snek/docs/docs/api.html\n@@ -0,0 +1,61 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\nnew file mode 100644\nindex 0000000..7c53bec\n--- /dev/null\n+++ b/src/snek/docs/docs/base.html\n@@ -0,0 +1,116 @@\n+<html>\n+\n+<head>\n+    <style>{{ highlight_styles }}</style>\n+    <style>\n+        \n+        * {\n+\n+            box-sizing: border-box;\n+        }\n+\n+        .dialog {\n+\n+            border-radius: 10px;\n+            padding: 30px;\n+            width: 800px;\n+            margin: 30px;\n+            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        }\n+\n+        @media screen and (max-width: 500px) {\n+            .center {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+            .dialog {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+        }\n+\n+        h1 {\n+            font-size: 2em;\n+            margin-bottom: 20px;\n+        }\n+\n+        h2 {\n+            font-size: 1.4em;\n+            margin-bottom: 20px;\n+        }\n+\n+        html,body,main {\n+            font-family: Arial, sans-serif;\n+            line-height: 1.5;\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            min-height: 100vh;\n+            width: 100%;\n+        }\n+        article {\n+            max-width: 100%;\n+            width: 60%;\n+            padding: 30px;\n+            min-height: 100vh;\n+            word-break: break-all;\n+        }\n+        footer {\n+            position: fixed;\n+            width: 60%;\n+            text-align: center; \n+            bottom: 0;\n+            left: 20%;\n+        }\n+        a {\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  header {\n+\n+    text-align: left;\n+    width: 60%;\n+    padding: 30px;\n+  }\n+  header a {\n+    display: inline;\n+\n+  }\n+        div {\n+            text-align: left;\n+\n+        }\n+    </style>\n+</head>\n+\n+<body>\n+    <main>\n+        <header>\n+            <a href=\"/\">Snek</a>\n+            <a href=\"/docs/docs\">Docs</a>\n+        </header>\n+      <article>\n+            {% block main %}\n+            {% endblock %}\n+        </article>\n+    </main>\n+    <footer>\n+        {% markdown %}\n+        {% endmarkdown %}\n+    </footer>\n+</body>\n+\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\nnew file mode 100644\nindex 0000000..c83ee7f\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_javascript.html\n@@ -0,0 +1,17 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - generic-form.js \n+\n+It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n+```html \n+<generic-form url=\"/url-to-form-api\"></generic-form>\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\nnew file mode 100644\nindex 0000000..d7e67bc\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_python.html\n@@ -0,0 +1,92 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - `snek.system.form.Form`\n+ - `snek.system.form.HTMLElement`\n+ - `snek.system.form.FormInputElement`\n+ - `snek.system.form.FormButtonElement`\n+\n+Here is an example with custom validation. \n+This example contains a field that checks if user already exists. \n+If invalid, it adds an error message which automatically invalidates the field. \n+Handling of the error messages will automatically done client side.\n+\n+Forms are usaly located in `snek/form/[form name].py`.\n+\n+```python\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\"\n+    )\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+```\n+\n+\n+```python \n+data = dict(\n+    username=dict(value=\"retoor\"),\n+    password=dict(value=\"retoorded\")\n+)\n+form.set_user_data(data)\n+\n+is_valid = await form.is_valid\n+\n+key_value_values = await form.record \n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\nnew file mode 100644\nindex 0000000..8ced8ab\n--- /dev/null\n+++ b/src/snek/docs/docs/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Snek is a high customizable chat application. \n+It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n+\n+This documentation is under construction. Only the form API and the small introduction is a bit documented.\n+\n+[Small introduction / cheatsheet](/docs/docs/api.html)\n+\n+With the view classes of Snek you can render HTML and Markdown \n+\n+Snek's database model is based on Python dataset library. \n+Snek uses a model/mapper architecture build on top of that library.\n+\n+Snek does have his own components for creating and rendering forms. \n+All forms are made server side and client side is generated client side using a HTML component.\n+It's client side only one line to include a form that can validate and submit.\n+Validation is server side using REST. Page won't refresh.\n+[API Python](/docs/docs/form_api_python.html) \n+[API Javascript](/docs/docs/form_api_javascript.html)\n+\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added cache directory to .gitignore", "commit": "82de0f304469e6214169a2bdcf9c65673baa9e76", "diff": "commit 82de0f304469e6214169a2bdcf9c65673baa9e76\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:42:12 2025 +0100\n\n    Added docks.\n\ndiff --git a/.gitignore b/.gitignore\nindex 5e03233..c1f3aef 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -7,7 +7,7 @@ snek.d*\n .rcontext.txt \n *.zip\n *.db*\n-*.png\n+cache\n __pycache__/"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added message template with markdown support", "commit": "80f1bbc05e612c45fb2ccbb629a6aa3b468c627e", "diff": "commit 80f1bbc05e612c45fb2ccbb629a6aa3b468c627e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:47:20 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex d2b8809..5739a59 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -5,6 +5,6 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n         {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added snek image", "commit": "9e89e27c6688b0e05e4a10a0538d599f82278e64", "diff": "commit 9e89e27c6688b0e05e4a10a0538d599f82278e64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:47:25 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/static/image/snek1.png b/src/snek/static/image/snek1.png\nnew file mode 100644\nindex 0000000..4dec2c2\nBinary files /dev/null and b/src/snek/static/image/snek1.png differ"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added notification audio and scheduling functionality", "commit": "af399e3b72c772ed97e943e7d71dc6384ab8ccc0", "diff": "commit af399e3b72c772ed97e943e7d71dc6384ab8ccc0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:08:40 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a125c4e..44853f1 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -345,13 +345,18 @@ class Socket extends EventHandler {\n \n }\n \n-class App extends EventHandler {\n-    rooms = []\n-    rest = rest\n-    ws = null\n-    rpc = null\n+class NotificationAudio {\n+    constructor(timeout){\n+        if(!timeout)\n+            timeout = 500\n+        this.schedule = new Schedule(timeout)\n+    }\n     sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n-    playSound(soundIndex) {\n+    play(soundIndex) {\n+        this.schedule.delay(() => {\n+            \n+        \n+\n         if (!soundIndex)\n             soundIndex = 0\n \n@@ -364,17 +369,30 @@ class App extends EventHandler {\n             .catch((error) => {\n                 console.error(\"Notification failed:\", error);\n             });\n+        })\n     }\n+}\n+\n+class App extends EventHandler {\n+    rooms = []\n+    rest = rest\n+    ws = null\n+    rpc = null\n+    audio = null \n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n         this.rpc = this.ws.client\n         const me = this\n+        this.audio = new NotificationAudio(500)\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n     }\n+    playSound(index){\n+        this.audio.play(index)\n+    }\n     async benchMark(times, message) {\n         if (!times)\n             times = 100\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 01fe8fb..c4b67cb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -55,7 +55,7 @@ class MessageListElement extends HTMLElement {\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n         if(message.html)\n-            text.innerHTML = this.linkifyText(message.html)\n+            text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 0eb41d1..3ed8069 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -17,8 +17,8 @@ class Schedule {\n         this.interval = null \n     }\n     cancelDelay() {\n-        clearTimeout(this.interval)\n-        this.interval = null\n+        clearTimeout(this.timeOut)\n+        this.timeOut = null\n     }\n     repeat(func){\n         if(this.interval){\n@@ -35,9 +35,10 @@ class Schedule {\n         }\n         const me = this \n         this.timeOut = setTimeout(()=>{\n+            func(me.timeOutCount)\n             clearTimeout(me.timeOut)\n             me.timeOut = null\n-            func(me.timeOutCount)\n+            \n             me.cancelDelay()\n             me.timeOutCount = 0\n         }, this.msDelay)"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added Docker configuration for deployment", "commit": "3be25285f4f0afaaf991ee7cc0a8f71854e8de4c", "diff": "commit 3be25285f4f0afaaf991ee7cc0a8f71854e8de4c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:12:25 2025 +0100\n\n    Added docks.\n\ndiff --git a/compose.yml b/compose.yml\nindex 24e186c..7be013d 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,6 +6,8 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+    environment:\n+     - PYTHONDONTWRITEBYTECODE=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n    \n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added markdown rendering to message content", "commit": "75cb7605cd5e8e91cab2ffbc9000eb5987e40136", "diff": "commit 75cb7605cd5e8e91cab2ffbc9000eb5987e40136\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:15:54 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 5739a59..037cebf 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -4,7 +4,7 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n+        {% markdown %}{% autoescape false %}{{ message }}  {%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render markdown messages with raw HTML support", "commit": "4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5", "diff": "commit 4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:17:27 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 037cebf..d813397 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -4,7 +4,7 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}  {%endautoescape%}{% endmarkdown %}\n+        {% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "chore: Remove compiled Python files", "commit": "f69586ccf7975be0bdd24659d6acec068f5183d6", "diff": "commit f69586ccf7975be0bdd24659d6acec068f5183d6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:19:49 2025 +0000\n\n    Deleted pyc\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\ndeleted file mode 100644\nindex 6f355a6..0000000\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and /dev/null differ"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added requests dependency and template extensions for linkification and python execution.", "commit": "bca39a612cad5f340864a4dc62d94cda962985f9", "diff": "commit bca39a612cad5f340864a4dc62d94cda962985f9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:12:22 2025 +0100\n\n    Update.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 71e90a7..22ec4eb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -23,6 +23,7 @@ dependencies = [\n     \"wkhtmltopdf\",\n     \"mistune\",\n     \"aiohttp-session\",\n-    \"cryptography\"\n+    \"cryptography\",\n+    \"requests\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex b1c20c0..c2c3651 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -16,6 +16,7 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n+from snek.system.template import LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n@@ -51,6 +52,9 @@ class Application(BaseApplication):\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n+        self.jinja2_env.add_extension(LinkifyExtension)\n+        self.jinja2_env.add_extension(PythonExtension)\n+        \n         self.setup_router()\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4b1e603..0e040e2 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,6 +209,11 @@ message-list {\n }\n \n .message {\n+  max-width: 100%;\n+  word-wrap: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n+\n   .avatar {\n       opacity: 0;\n   }\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nnew file mode 100644\nindex 0000000..8994528\n--- /dev/null\n+++ b/src/snek/system/template.py\n@@ -0,0 +1,99 @@\n+from types import SimpleNamespace\n+from bs4 import BeautifulSoup\n+import re \n+\n+\n+\n+def set_link_target_blank(text):\n+    soup = BeautifulSoup(text, 'html.parser')\n+\n+    for element in soup.find_all(\"a\"):  \n+        element.attrs['target'] = '_blank'\n+        element.attrs['rel'] = 'noopener noreferrer'\n+        element.attrs['referrerpolicy'] = 'no-referrer'\n+\n+    return str(soup)\n+    \n+\n+def linkify_https(text):\n+\n+    soup = BeautifulSoup(text, 'html.parser')\n+\n+    for element in soup.find_all(text=True):  \n+        parent = element.parent\n+        if parent.name in ['a', 'script', 'style']:  \n+            continue\n+        \n+        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n+        element.replace_with(BeautifulSoup(new_text, 'html.parser'))\n+\n+    return set_link_target_blank(str(soup))\n+\n+\n+from jinja2 import TemplateSyntaxError, nodes\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n+\n+\n+class LinkifyExtension(Extension):\n+    tags = {\"linkify\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(LinkifyExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return linkify_https(caller())\n+\n+class PythonExtension(Extension):\n+    tags = {\"py3\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        \n+        def fn(source):\n+            import subprocess \n+            import subprocess \n+            import pathlib \n+            from pathlib import Path \n+            import os \n+            import sys \n+            import requests\n+            def system(command):\n+                if isinstance(command):\n+                    command = command.split(\" \")\n+                from io import StringIO \n+                stdout = StringIO()\n+                subprocess.run(command,stderr=stdout,stdout=stdout,text=True)\n+                return stdout.getvalue()\n+            to_write = []\n+            def render(text):\n+                global to_write \n+                to_write.append(text)\n+            exec(source)\n+            return \"\".join(to_write)\n+        return str(fn(caller()))\n\\ No newline at end of file\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex d813397..36faaed 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,10 +1,14 @@\n <style>\n     {{highlight_styles}}\n </style>\n+\n-    <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n+    <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n+{% linkify %}\n+\n+{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n+{% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Style message images and improve time display", "commit": "5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c", "diff": "commit 5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:56:28 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0e040e2..c8af379 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,11 +209,16 @@ message-list {\n }\n \n .message {\n+  .text {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n   hyphens: auto;\n-\n+    img {\n+      max-width: 90%;\n+      border-radius: 20px;\n+    }\n+  }\n   .avatar {\n       opacity: 0;\n   }\n@@ -227,6 +232,16 @@ message-list {\n   }\n }\n .message.switch-user {\n+  .text {\n+  max-width: 100%;\n+  word-wrap: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n+    img{\n+      max-width: 90%;\n+      border-radius: 20px;\n+    }\n+  }\n   .avatar {\n       opacity: 1;\n   }\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex c4b67cb..3038ad3 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -25,6 +25,34 @@ class MessageListElement extends HTMLElement {\n             });\n         \n     }\n+    timeAgo(date1, date2) {\n+    \n+        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n+        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n+        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n+        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+        if(days){\n+            return `${days} days ago`\n+        }\n+        if(hours){\n+            return `${hours} hours ago`\n+        }\n+        if (minutes)\n+            return `${minutes} minutes ago`\n+        \n+        return `just now`\n+    }\n+    timeDescription(isoDate){\n+        if(!isoDate.endsWith(\"Z\"))\n+            isoDate += \"Z\"\n+        const date =  new Date(isoDate)\n+        const hours = String(date.getHours()).padStart(2, \"0\");\n+        const minutes = String(date.getMinutes()).padStart(2, \"0\");\n+        let timeStr = `${hours}:${minutes}`\n+        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now()) \n+        return timeStr\n+    }\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n@@ -58,8 +86,9 @@ class MessageListElement extends HTMLElement {\n             text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n-        time.textContent = message.created_at\n+        time.dataset.created_at = message.created_at\n         messageContent.appendChild(author)\n+        time.textContent = this.timeDescription(message.created_at)\n         messageContent.appendChild(text)\n         messageContent.appendChild(time)\n         element.appendChild(avatar)\n@@ -119,6 +148,15 @@ class MessageListElement extends HTMLElement {\n         })\n         this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n         \n+        this.timeUpdateInterval = setInterval(()=>{\n+            me.messages.forEach((message)=>{\n+                const newText = me.timeDescription(message.created_at)\n+                \n+                if(newText != message.element.innerText){\n+                message.element.querySelector(\".time\").innerText = newText\n+                }\n+            })\n+        },30000)\n         \n     }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Improve message timestamps and linkify URLs", "commit": "20d8d27f03e87bf06515d0664a00e669b92df49f", "diff": "commit 20d8d27f03e87bf06515d0664a00e669b92df49f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:58:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 3038ad3..7211ddb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -1,7 +1,7 @@\n \n \n class MessageListElement extends HTMLElement {\n-    \n+\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n@@ -9,51 +9,60 @@ class MessageListElement extends HTMLElement {\n     room = null\n     url = null\n     container = null\n-    messageEventSchedule = null \n-    observer = null \n+    messageEventSchedule = null\n+    observer = null\n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n-        this.shadowRoot.appendChild(this.component )\n+        this.shadowRoot.appendChild(this.component)\n     }\n     linkifyText(text) {\n-            const urlRegex = /https?:\\/\\/[^\\s]+/g;\n-        \n-            return text.replace(urlRegex, (url) => {\n-                return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n-            });\n-        \n+        const urlRegex = /https?:\\/\\/[^\\s]+/g;\n+\n+        return text.replace(urlRegex, (url) => {\n+            return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n+        });\n+\n     }\n     timeAgo(date1, date2) {\n-    \n+\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n         const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n         const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n-        if(days){\n-            return `${days} days ago`\n+        if (days) {\n+            if (days > 1)\n+                return `${days} days ago`\n+            else\n+                return `${days} day ago`\n         }\n-        if(hours){\n-            return `${hours} hours ago`\n+        if (hours) {\n+            if (hours > 1)\n+                return `${hours} hours ago`\n+            else\n+                return `${hours} hour ago`\n         }\n         if (minutes)\n-            return `${minutes} minutes ago`\n-        \n+            if (minutes > 1)\n+                return `${minutes} minutes ago`\n+            else\n+                return `${minutes} minute ago`\n+\n         return `just now`\n     }\n-    timeDescription(isoDate){\n-        if(!isoDate.endsWith(\"Z\"))\n+    timeDescription(isoDate) {\n+        if (!isoDate.endsWith(\"Z\"))\n             isoDate += \"Z\"\n-        const date =  new Date(isoDate)\n+        const date = new Date(isoDate)\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n         let timeStr = `${hours}:${minutes}`\n-        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now()) \n+        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now())\n         return timeStr\n     }\n-    createElement(message){\n+    createElement(message) {\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n         element.dataset.color = message.color\n@@ -61,18 +70,18 @@ class MessageListElement extends HTMLElement {\n         element.dataset.user_nick = message.user_nick\n         element.dataset.created_at = message.created_at\n         element.dataset.user_uid = message.user_uid\n-        element.dataset.message = message.message \n-        \n+        element.dataset.message = message.message\n+\n         element.classList.add(\"message\")\n-        if(!this.messages.length){\n+        if (!this.messages.length) {\n             element.classList.add(\"switch-user\")\n-        }else if (this.messages[this.messages.length-1].user_uid != message.user_uid){\n+        } else if (this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n             element.classList.add(\"switch-user\")\n         }\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color\n-        avatar.style.color= \"black\"\n+        avatar.style.color = \"black\"\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")\n@@ -82,7 +91,7 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        if(message.html)\n+        if (message.html)\n             text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n@@ -94,15 +103,15 @@ class MessageListElement extends HTMLElement {\n         element.appendChild(avatar)\n         element.appendChild(messageContent)\n \n-    \n \n \n-        message.element = element \n-       \n+\n+        message.element = element\n+\n         return element\n     }\n-    addMessage(message){\n-        \n+    addMessage(message) {\n+\n         const obj = new models.Message(\n             message.uid,\n             message.channel_uid,\n@@ -117,17 +126,17 @@ class MessageListElement extends HTMLElement {\n         const element = this.createElement(obj)\n         this.messages.push(obj)\n         this.container.appendChild(element)\n-        const me = this \n-        \n+        const me = this\n+\n         this.messageEventSchedule.delay(() => {\n-            me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n-        \n+            me.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }))\n+\n         })\n-       \n+\n \n         return obj\n     }\n-    scrollBottom(){\n+    scrollBottom() {\n         this.container.scrollTop = this.container.scrollHeight;\n     }\n     connectedCallback() {\n@@ -146,18 +155,18 @@ class MessageListElement extends HTMLElement {\n         app.addEventListener(this.channel_uid, (data) => {\n             me.addMessage(data)\n         })\n-        this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n-        \n-        this.timeUpdateInterval = setInterval(()=>{\n-            me.messages.forEach((message)=>{\n+        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }))\n+\n+        this.timeUpdateInterval = setInterval(() => {\n+            me.messages.forEach((message) => {\n                 const newText = me.timeDescription(message.created_at)\n-                \n-                if(newText != message.element.innerText){\n-                message.element.querySelector(\".time\").innerText = newText\n+\n+                if (newText != message.element.innerText) {\n+                    message.element.querySelector(\".time\").innerText = newText\n                 }\n             })\n-        },30000)\n-        \n+        }, 30000)\n+\n     }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Removed unnecessary linkify_https function", "commit": "3d6e1d2e943baabaf0b0875284bf18132bc3967a", "diff": "commit 3d6e1d2e943baabaf0b0875284bf18132bc3967a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:13:41 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8994528..69222b6 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -16,6 +16,8 @@ def set_link_target_blank(text):\n     \n \n def linkify_https(text):\n+        return text \n \n     soup = BeautifulSoup(text, 'html.parser')"}
{"repo": ".", "date": "2025-01-29", "line": "style: Removed unnecessary padding from root element", "commit": "5c4c5793899776e5d369f3949b4a8142a68ba7ee", "diff": "commit 5c4c5793899776e5d369f3949b4a8142a68ba7ee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:17:01 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex c8af379..42d4b33 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,6 +1,6 @@\n * {\n   margin: 0;\n-  padding: 0;\n   box-sizing: border-box;\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Display install button inline-block", "commit": "a8e3ad1af9f683ad25730ff48180c5306f72e1f6", "diff": "commit a8e3ad1af9f683ad25730ff48180c5306f72e1f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:47:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1d660f6..b2e9677 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -52,7 +52,7 @@ let installPrompt = null\n             const result = await installPrompt.prompt()\n             console.info(result.outcome)\n             })\n-            button.style.display = 'block'\n+            button.style.display = 'inline-block'\n             \n           });\n         ;"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added username to chat messages", "commit": "99cea506de5ea0c5b373869b7c28d965b8af55e6", "diff": "commit 99cea506de5ea0c5b373869b7c28d965b8af55e6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 22:15:25 2025 +0100\n\n    Username support.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex b31f747..02a4bd5 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -24,6 +24,7 @@ class ChatService(BaseService):\n             channel_uid=channel_uid,\n             created_at=channel_message[\"created_at\"], \n             updated_at=None,\n+            username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 6bbfa20..b48f4d0 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -74,7 +74,8 @@ class RPCView(BaseView):\n                     user_nick=user['nick'],\n                     message=message[\"message\"],\n                     created_at=message[\"created_at\"],\n-                    html=message['html'] \n+                    html=message['html'],\n+                    username=user['username'] \n                 ))\n             return messages"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected time difference calculation", "commit": "03e90039695abc0fdc9276980bd8728bd8951f05", "diff": "commit 03e90039695abc0fdc9276980bd8728bd8951f05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 23:20:35 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7211ddb..67a12c2 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -26,7 +26,7 @@ class MessageListElement extends HTMLElement {\n \n     }\n     timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1); \n \n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added snecssh service and asyncssh dependency for SSH functionality", "commit": "b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5", "diff": "commit b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:06:23 2025 +0100\n\n    Added traceback\n\ndiff --git a/compose.yml b/compose.yml\nindex 7be013d..ce2b02c 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -10,4 +10,17 @@ services:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n-   \n\\ No newline at end of file\n+  snecssh:\n+    build:\n+      context: .\n+      dockerfile: DockerfileDrive\n+    restart: always\n+    ports:\n+      - \"2225:2225\"\n+    volumes:\n+      - ./:/code\n+    environment:\n+     - PYTHONDONTWRITEBYTECODE=\"1\"\n+    entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]\n+   \ndiff --git a/pyproject.toml b/pyproject.toml\nindex 22ec4eb..a2a0ccb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -24,6 +24,7 @@ dependencies = [\n     \"mistune\",\n     \"aiohttp-session\",\n     \"cryptography\",\n-    \"requests\"\n+    \"requests\",\n+    \"asyncssh\"\n ]\n \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 42d4b33..46b9fef 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -4,6 +4,29 @@\n   box-sizing: border-box;\n }\n \n+.gallery {\n+  padding: 50px;\n+  height: auto;\n+  overflow: auto;\n+  flex: 1;\n+  &.tile {\n+  width: 100px;\n+  height: 100px;\n+  object-fit: cover;\n+  margin-right: 10px;\n+  border-radius: 5px;\n+  margin: 20px;\n+  }\n+}\n+.tile {\n+  \n+    width: 100px;\n+    height: 100px;\n+    object-fit: cover;\n+    margin-right: 10px;\n+    border-radius: 5px;\n+    margin: 20px;\n+}\n \n body {\n   font-family: Arial, sans-serif;\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex bdad37e..58845b4 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -23,6 +23,7 @@ class ChatWindowElement extends HTMLElement {\n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n         \n+\n         \n         \n         const chatTitle = document.createElement('h2')\n@@ -37,7 +38,13 @@ class ChatWindowElement extends HTMLElement {\n         channelElement.setAttribute(\"channel\", channel.uid)\n         this.container.appendChild(channelElement)\n-\n         const chatInput = document.createElement('chat-input')\n         \n         chatInput.addEventListener(\"submit\",(e)=>{\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex b2e9677..3882548 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script src=\"/media-upload.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b48f4d0..3619bd6 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,5 +1,6 @@\n from aiohttp import web  \n from snek.system.view import BaseView\n+import traceback\n \n \n class RPCView(BaseView):\n@@ -117,7 +118,11 @@ class RPCView(BaseView):\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n-                result = await method(*args)\n+                try:\n+                    result = await method(*args)\n+                except Exception as ex:\n+                    result = dict({\"callId\":call_id,\"success\": False, \"data\":{\"exception\":str(ex),\"traceback\":traceback.format_exc()}}) \n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n                 await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n@@ -145,4 +150,4 @@ class RPCView(BaseView):\n \n         await self.services.socket.delete(ws)\n         print(\"WebSocket connection closed\")\n-        return ws\n\\ No newline at end of file\n+        return ws"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Initialized drive functionality with Dockerfile and basic JS components", "commit": "15de277a5be330fe6962e5271c537e3b5ef40de4", "diff": "commit 15de277a5be330fe6962e5271c537e3b5ef40de4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:06:42 2025 +0100\n\n    drive.\n\ndiff --git a/DockerfileDrive b/DockerfileDrive\nnew file mode 100644\nindex 0000000..28f183a\n--- /dev/null\n+++ b/DockerfileDrive\n@@ -0,0 +1,16 @@\n+FROM python:3.12.8-alpine3.21\n+WORKDIR /code\n+RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n+\n+\n+COPY setup.cfg setup.cfg \n+COPY pyproject.toml pyproject.toml \n+COPY src src\n+COpy ssh_host_key ssh_host_key\n+RUN pip install --upgrade pip\n+RUN pip install -e .\n+EXPOSE 2225\n+\ndiff --git a/src/snek/static/media-upload.js b/src/snek/static/media-upload.js\nnew file mode 100644\nindex 0000000..e190aed\n--- /dev/null\n+++ b/src/snek/static/media-upload.js\n@@ -0,0 +1,167 @@\n+class TileGridElement extends HTMLElement {\n+                \n+    constructor() {\n+        super();\n+        this.attachShadow({mode: 'open'});\n+        this.gridId = this.getAttribute('grid');\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component)\n+    }\n+  \n+\n+    connectedCallback() {\n+        console.log('connected');\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerText = `\n+            .grid {\n+            padding: 10px;\n+                display: flex;\n+                flex-wrap: wrap;\n+                gap: 10px;\n+                justify-content: center;\n+            }\n+            .grid .tile {\n+                margin: 10px;\n+                width: 100px;\n+                height: 100px;\n+                object-fit: cover;\n+                border-radius: 10px;\n+                transition: transform 0.3s ease-in-out;\n+            }\n+            .grid .tile:hover {\n+                transform: scale(1.1);\n+            }\n+\n+        `;\n+        this.component.appendChild(this.styleElement);\n+        this.container = document.createElement('div');\n+        this.container.classList.add('gallery');\n+        this.component.appendChild(this.container);\n+    }\n+    addImage(src) {\n+        const item = document.createElement('img');\n+        item.src = src;\n+        item.classList.add('tile');\n+        item.style.width = '100px';\n+        item.style.height = '100px';\n+        this.container.appendChild(item);\n+    }\n+    addImages(srcs) {\n+        srcs.forEach(src => this.addImage(src));\n+    }\n+    addElement(element) {\n+        element.cclassList.add('tile');\n+        this.container.appendChild(element);\n+    }\n+\n+}\n+\n+class UploadButton extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({mode: 'open'});\n+        this.component = document.createElement('div');\n+    \n+        this.shadowRoot.appendChild(this.component)\n+        window.u = this\n+    }\n+    get gridSelector(){\n+        return this.getAttribute('grid');\n+    }\n+    grid = null\n+\n+    addImages(urls) {\n+        this.grid.addImages(urls);\n+    }\n+    connectedCallback()\n+    {\n+        console.log('connected');\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerHTML = `\n+        .upload-button {\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            justify-content: center;\n+            gap: 10px;\n+        }\n+        .upload-button input[type=\"file\"] {\n+            display: none;\n+        }\n+        .upload-button label {\n+            display: block;\n+            padding: 10px 20px;\n+            border-radius: 5px;\n+            cursor: pointer;\n+        }\n+        .upload-button label:hover {\n+        }\n+        `;\n+        this.shadowRoot.appendChild(this.styleElement);\n+        this.container = document.createElement('div');\n+        this.container.classList.add('upload-button');\n+        this.shadowRoot.appendChild(this.container);\n+        const input = document.createElement('input');\n+        input.type = 'file';\n+        input.multiple = true;\n+        input.addEventListener('change', (e) => {\n+            const files = e.target.files;\n+            const urls = [];\n+            for (let i = 0; i < files.length; i++) {\n+                const file = files[i];\n+                const reader = new FileReader();\n+                reader.onload = (e) => {\n+                    urls.push(e.target.result);\n+                    if (urls.length === files.length) {\n+                        this.addImages(urls);\n+                    }\n+                };\n+                reader.readAsDataURL(file);\n+            }\n+         });\n+        const label = document.createElement('label');\n+        label.textContent = 'Upload Images';\n+        label.appendChild(input);\n+        this.container.appendChild(label);\n+    }\n+}\n+\n+customElements.define('upload-button', UploadButton);  \n+\n+customElements.define('tile-grid', TileGridElement);\n+\n+class MeniaUploadElement extends HTMLElement {\n+\n+    constructor(){\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.component = document.createElement(\"div\")\n+        alert('aaaa')\n+        this.shadowRoot.appendChild(this.component)\n+    }\n+    connectedCallback() {\n+        \n+        this.container = document.createElement(\"div\")\n+        this.component.style.height = '100%'\n+        this.component.style.backgroundColor ='blue';\n+        this.shadowRoot.appendChild(this.container)\n+\n+        this.tileElement = document.createElement(\"tile-grid\")\n+        this.tileElement.style.backgroundColor = 'red'\n+        this.tileElement.style.height = '100%'\n+        this.component.appendChild(this.tileElement)\n+        \n+        this.uploadButton = document.createElement('upload-button')\n+        this.component.appendChild(this.uploadButton)\n+        \n+    }\n+\n+}\n+\n+customElements.define('menia-upload', MeniaUploadElement)\n\\ No newline at end of file\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nnew file mode 100644\nindex 0000000..27e1b4b\n--- /dev/null\n+++ b/src/snekssh/app.py\n@@ -0,0 +1,71 @@\n+import asyncio\n+import asyncssh\n+import os\n+import logging\n+asyncssh.set_debug_level(2)\n+logging.basicConfig(level=logging.DEBUG)\n+USERNAME = \"test\"\n+PASSWORD = \"woeii\"\n+HOST = \"localhost\"\n+PORT = 2225\n+\n+class MySFTPServer(asyncssh.SFTPServer):\n+    def __init__(self, chan):\n+        super().__init__(chan)\n+        self.root = os.path.abspath(SFTP_ROOT)\n+\n+    async def stat(self, path):\n+        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().stat(full_path)\n+\n+    async def open(self, path, flags, attrs):\n+        \"\"\"Handles file open requests\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().open(full_path, flags, attrs)\n+\n+    async def listdir(self, path):\n+        \"\"\"Handles directory listing\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().listdir(full_path)\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    \"\"\"Custom SSH server to handle authentication\"\"\"\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def begin_auth(self, username):\n+\n+    def password_auth_supported(self):\n+\n+    def validate_password(self, username, password):\n+        print(username,password)\n+        \n+        return True\n+        return username == USERNAME and password == PASSWORD\n+\n+async def start_sftp_server():\n+\n+    await asyncssh.create_server(\n+        lambda: MySSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=MySFTPServer\n+    )\n+    print(f\"SFTP server running on {HOST}:{PORT}\")\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_sftp_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SFTP server: {e}\")\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nnew file mode 100644\nindex 0000000..1bfec21\n--- /dev/null\n+++ b/src/snekssh/app2.py\n@@ -0,0 +1,68 @@\n+import asyncio\n+import asyncssh\n+import os\n+\n+HOST = \"0.0.0.0\"\n+PORT = 2225\n+USERNAME = \"user\"\n+PASSWORD = \"password\"\n+\n+class CustomSSHServer(asyncssh.SSHServer):\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        return username == USERNAME and password == PASSWORD\n+\n+async def custom_bash_process(process):\n+    \"\"\"Spawns a custom bash shell process\"\"\"\n+    env = os.environ.copy()\n+    env[\"TERM\"] = \"xterm-256color\"\n+\n+    bash_proc = await asyncio.create_subprocess_exec(\n+        SHELL, \"-i\", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env\n+    )\n+\n+    async def read_output():\n+        while True:\n+            data = await bash_proc.stdout.read(1)\n+            if not data:\n+                break\n+            process.stdout.write(data)\n+\n+    async def read_input():\n+        while True:\n+            data = await process.stdin.read(1)\n+            if not data:\n+                break\n+            bash_proc.stdin.write(data)\n+\n+    await asyncio.gather(read_output(), read_input())\n+\n+async def start_ssh_server():\n+    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n+    await asyncssh.create_server(\n+        lambda: CustomSSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=custom_bash_process\n+    )\n+    print(f\"SSH server running on {HOST}:{PORT}\")\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_ssh_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SSH server: {e}\")\n+\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nnew file mode 100644\nindex 0000000..d50cc54\n--- /dev/null\n+++ b/src/snekssh/app3.py\n@@ -0,0 +1,71 @@\n+\n+\n+import asyncio, asyncssh, sys\n+\n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+\n+\n+\n+\n+\n+    width, height, pixwidth, pixheight = process.term_size\n+\n+    process.stdout.write(f'Terminal type: {process.term_type}, '\n+                         f'size: {width}x{height}')\n+    if pixwidth and pixheight:\n+        process.stdout.write(f' ({pixwidth}x{pixheight} pixels)')\n+    process.stdout.write('\\nTry resizing your window!\\n')\n+\n+    while not process.stdin.at_eof():\n+        try:\n+            await process.stdin.read()\n+        except asyncssh.TerminalSizeChanged as exc:\n+            process.stdout.write(f'New window size: {exc.width}x{exc.height}')\n+            if exc.pixwidth and exc.pixheight:\n+                process.stdout.write(f' ({exc.pixwidth}'\n+                                     f'x{exc.pixheight} pixels)')\n+            process.stdout.write('\\n')\n+\n+\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen('', 2230, server_host_keys=['ssh_host_key'],\n+                          process_factory=handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nnew file mode 100644\nindex 0000000..e54480f\n--- /dev/null\n+++ b/src/snekssh/app4.py\n@@ -0,0 +1,77 @@\n+\n+\n+import asyncio, asyncssh, bcrypt, sys\n+from typing import Optional\n+\n+             'user': bcrypt.hashpw(b'user', bcrypt.gensalt()),\n+            }\n+\n+def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+    username = process.get_extra_info('username')\n+    process.stdout.write(f'Welcome to my SSH server, {username}!\\n')\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n+        peername = conn.get_extra_info('peername')[0]\n+        print(f'SSH connection received from {peername}.')\n+\n+    def connection_lost(self, exc: Optional[Exception]) -> None:\n+        if exc:\n+            print('SSH connection error: ' + str(exc), file=sys.stderr)\n+        else:\n+            print('SSH connection closed.')\n+\n+    def begin_auth(self, username: str) -> bool:\n+        return passwords.get(username) != b''\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        if username not in passwords:\n+            return False\n+        pw = passwords[username]\n+        if not password and not pw:\n+            return True\n+        return bcrypt.checkpw(password.encode('utf-8'), pw)\n+\n+async def start_server() -> None:\n+    await asyncssh.create_server(MySSHServer, '', 2231,\n+                                 server_host_keys=['ssh_host_key'],\n+                                 process_factory=handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nnew file mode 100644\nindex 0000000..bbaf3d3\n--- /dev/null\n+++ b/src/snekssh/app5.py\n@@ -0,0 +1,106 @@\n+\n+\n+import asyncio, asyncssh, sys\n+from typing import List, cast\n+\n+class ChatClient:\n+    _clients: List['ChatClient'] = []\n+\n+    def __init__(self, process: asyncssh.SSHServerProcess):\n+        self._process = process\n+\n+    @classmethod\n+    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n+        await cls(process).run()\n+\n+    \n+\n+    async def readline(self) -> str:\n+        return cast(str, self._process.stdin.readline())\n+\n+    def write(self, msg: str) -> None:\n+        self._process.stdout.write(msg)\n+\n+    def broadcast(self, msg: str) -> None:\n+        for client in self._clients:\n+            if client != self:\n+                client.write(msg)\n+\n+\n+    def begin_auth(self, username: str) -> bool:\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        return True\n+\n+\n+    async def run(self) -> None:\n+        self.write('Welcome to chat!\\n\\n')\n+\n+        self.write('Enter your name: ')\n+        name = (await self.readline()).rstrip('\\n')\n+\n+        self.write(f'\\n{len(self._clients)} other users are connected.\\n\\n')\n+\n+        self._clients.append(self)\n+        self.broadcast(f'*** {name} has entered chat ***\\n')\n+\n+        try:\n+            async for line in self._process.stdin:\n+                self.broadcast(f'{name}: {line}')\n+        except asyncssh.BreakReceived:\n+            pass\n+\n+        self.broadcast(f'*** {name} has left chat ***\\n')\n+        self._clients.remove(self)\n+\n+async def start_server() -> None:\n+    await asyncssh.listen('', 2235, server_host_keys=['ssh_host_key'],\n+                          process_factory=ChatClient.handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/ssh_host_key b/ssh_host_key\nnew file mode 100644\nindex 0000000..b65e9f2\n--- /dev/null\n+++ b/ssh_host_key\n@@ -0,0 +1,27 @@\n+-----BEGIN OPENSSH PRIVATE KEY-----\n+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\n+NhAAAAAwEAAQAAAQEAqKVFRuoa+WMGl0Z6UAYu943G9pInhd8nnsqPinu6t+D6X9U4A1bF\n+u46JBfIdwJMHoGUzVnLdDL9OO38yvZXnDXmRSEQmQhYL981BwFSFPBRNPYrY5bF6qgM6IW\n+gH8RFL5tLA5RZoUpoyKzwIllXIyKHIcxwIf6be2G8KrheuXu9/Le0Tq3f6n3LhPEwhPD0m\n+7Nah3LZS4cU+G3TcET8jh6Nw/uE7wV11ojzjrn0Fa+2HCyUSGvp4Xt9gNZmlOrY4l+22DK\n+ZUf/Cv+0ebMpngaMjYfYKFWCprwElf/YxKn6NKCcJ2KaU9kUKACpitN8DzrwA6o6VROCQ1\n+JmPq8b0PmwAAA8gZwhWRGcIVkQAAAAdzc2gtcnNhAAABAQCopUVG6hr5YwaXRnpQBi73jc\n+b2kieF3yeeyo+Ke7q34Ppf1TgDVsW7jokF8h3AkwegZTNWct0Mv047fzK9lecNeZFIRCZC\n+Fgv3zUHAVIU8FE09itjlsXqqAzohaAfxEUvm0sDlFmhSmjIrPAiWVcjIochzHAh/pt7Ybw\n+quF65e738t7ROrd/qfcuE8TCE8PSbs1qHctlLhxT4bdNwRPyOHo3D+4TvBXXWiPOOufQVr\n+7YcLJRIa+nhe32A1maU6tjiX7bYMplR/8K/7R5symeBoyNh9goVYKmvASV/9jEqfo0oJwn\n+YppT2RQoAKmK03wPOvADqjpVE4JDUmY+rxvQ+bAAAAAwEAAQAAAQAGgb66U+s2HUSY1TOA\n+H1NalWuZNRE1tuMP2yyBbfEWifI/FlPyu26McQOgz7NA+RGw3GOIF1oOCHRbrINOeBhesO\n+0SrVNCKeWZg9Lgn9VpxWkn61G5DKL5KLMdrNmsytUNExHPa121EJWU83XSKLDDHox3j7WI\n+PCEAl5aa5vdnjdf5LXmZKzVECx7pbEbQvvcC/8uTjK4nfBphVDGY43C8mo7hNeSIl6Rpcm\n+Vr2W3p8PXahYjHjMoKasosiuyf79lxrTUXbGTVjI3eiBAAAAgQCXYkyC83how+QsnygxZi\n+1xuE4hTD0BhCWTJFtuuKAIb3uib3ciMkxxy5qbfW2AfHb3vngqim+rPAwoxW55YuLTs5zu\n+1yO5k1ieWgn/ubDWLr3j8+1yCrVSha33Hyd6/NaffIuLM4Oy72zKrAq73b5tWrLNvllxic\n+i/kZ5YkYbQrQAAAIEA3RZMiNtHMB0xev2gf6bPYxYvPoZvzd66p+P1+4EVTdCrYAdRZLWZ\n+UETfWZt6YZYwbRpwrZatOampyEUy6ApQH4ga75LBRQo0P3SXP441XZucWm6X4PRYyKY7VT\n+fhfAdgbrUBOPcOrEAdBT3W56PjPnY6apUXy07xSoZ7WuhLKMEAAACBAMNG9n+7o0rBtxZe\n+6DCtM8xYsCh122+NWiLRck95rxYzhv2xm0k3xLE2CdIZuM4+KJnG/5SwOxDY2cHdkXpF+6\n+InwyWxvnV6TiCsLxYrsmMToHZP7U1mwdxWaV0xySxhaIamwnYJOqYm4uNaL8VTaNzkGzjs\n+quwPw1GCjmh9j1NbAAAADnJldG9vckByZXRvb3IyAQIDBA==\n+-----END OPENSSH PRIVATE KEY-----\ndiff --git a/ssh_host_key.pub b/ssh_host_key.pub\nnew file mode 100644\nindex 0000000..ca1693e\n--- /dev/null\n+++ b/ssh_host_key.pub\n@@ -0,0 +1 @@\n+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCopUVG6hr5YwaXRnpQBi73jcb2kieF3yeeyo+Ke7q34Ppf1TgDVsW7jokF8h3AkwegZTNWct0Mv047fzK9lecNeZFIRCZCFgv3zUHAVIU8FE09itjlsXqqAzohaAfxEUvm0sDlFmhSmjIrPAiWVcjIochzHAh/pt7YbwquF65e738t7ROrd/qfcuE8TCE8PSbs1qHctlLhxT4bdNwRPyOHo3D+4TvBXXWiPOOufQVr7YcLJRIa+nhe32A1maU6tjiX7bYMplR/8K/7R5symeBoyNh9goVYKmvASV/9jEqfo0oJwnYppT2RQoAKmK03wPOvADqjpVE4JDUmY+rxvQ+b retoor@retoor2"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Enable unbuffered python output", "commit": "8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6", "diff": "commit 8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:17:10 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/compose.yml b/compose.yml\nindex ce2b02c..ced4111 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -8,6 +8,7 @@ services:\n       - ./:/code\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n+     - PYTHONUNBUFFERED=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n@@ -21,6 +22,7 @@ services:\n       - ./:/code\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n+     - PYTHONUNBUFFERED=\"1\"\n     entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for response messages", "commit": "4de93489ef01bf070f461915989be611156121dd", "diff": "commit 4de93489ef01bf070f461915989be611156121dd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:21:53 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3619bd6..bdcd025 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -78,6 +78,7 @@ class RPCView(BaseView):\n                     html=message['html'],\n                     username=user['username'] \n                 ))\n+                print(\"Response messages:\",messages,flush=True)\n             return messages\n         \n         async def get_channels(self):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Improve socket error logging and flushing", "commit": "1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d", "diff": "commit 1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:31:22 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 0c11208..92fd33d 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -27,8 +27,8 @@ class SocketService(BaseService):\n             try:\n                 await ws.send_json(message)\n             except Exception as ex:\n-                print(ex)\n-                print(\"Deleting socket.\")\n+                print(ex,flush=True)\n+                print(\"Deleting socket.\",flush=True)\n                 self.subscriptions[channel_uid].remove(ws)\n                 continue \n             count += 1\n@@ -37,4 +37,4 @@ class SocketService(BaseService):\n         try:\n             self.sockets.remove(ws) \n         except IndexError:\n-            pass \n\\ No newline at end of file\n+            pass"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for incoming websocket messages", "commit": "312b9eeecaee5d16247a7f0c694e3168893c389d", "diff": "commit 312b9eeecaee5d16247a7f0c694e3168893c389d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:34:06 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex bdcd025..431bf72 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -144,6 +144,7 @@ class RPCView(BaseView):\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n+            print(msg)\n             if msg.type == web.WSMsgType.TEXT:\n                 await rpc(msg.json())\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Simplify error response in RPCView", "commit": "c6f43931664c01c597c642e35c64ec49f3008101", "diff": "commit c6f43931664c01c597c642e35c64ec49f3008101\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:38:21 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 431bf72..cd12786 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -122,7 +122,7 @@ class RPCView(BaseView):\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"callId\":call_id,\"success\": False, \"data\":{\"exception\":str(ex),\"traceback\":traceback.format_exc()}}) \n+                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()}) \n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n@@ -137,10 +137,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\") is True:\n-            await self.services.socket.add(ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Add unbuffered print statement for subscription label", "commit": "5fd03efc301d722a5ee09c8b0cef6d04c1130fd3", "diff": "commit 5fd03efc301d722a5ee09c8b0cef6d04c1130fd3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:38:58 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex cd12786..db11e80 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -141,7 +141,7 @@ class RPCView(BaseView):\n-            print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n             print(msg)"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Handle websocket close events and improve socket deletion", "commit": "780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64", "diff": "commit 780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:43:38 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 92fd33d..b058044 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -36,5 +36,5 @@ class SocketService(BaseService):\n     async def delete(self, ws):\n         try:\n             self.sockets.remove(ws) \n-        except IndexError:\n+        except :\n             pass \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex db11e80..5cfc284 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -149,7 +149,8 @@ class RPCView(BaseView):\n                 await rpc(msg.json())\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n-\n-        await self.services.socket.delete(ws)\n+                await self.services.socket.delete(ws)\n+            elif msg.type == web.WSMsgType.CLOSE:\n+                await self.services.socket.delete(ws)\n         print(\"WebSocket connection closed\")\n         return ws"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Added logging for RPC exceptions", "commit": "cc3b896d2cd80affc251434800844829cc3fb6e1", "diff": "commit cc3b896d2cd80affc251434800844829cc3fb6e1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:45:42 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5cfc284..5a600e3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -122,7 +122,8 @@ class RPCView(BaseView):\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()}) \n+                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    print(result,flush=True)\n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Use internal send method for RPC responses", "commit": "0a70e80668a598c909674c78b654b5ad8e6afce5", "diff": "commit 0a70e80668a598c909674c78b654b5ad8e6afce5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:49:47 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5a600e3..4372e34 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -115,7 +115,7 @@ class RPCView(BaseView):\n                     raise Exception(\"Not allowed\")\n                 args = data.get(\"args\")\n                 if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                    return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+                    return await self._send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n@@ -125,9 +125,12 @@ class RPCView(BaseView):\n                     result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n                     print(result,flush=True)\n-                await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+                await self._send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n-                await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+                await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+\n+    async def _send_json(self,obj):\n+        await self.ws.send_text(json.dumps(obj,default=str))\n \n     async def call_ping(self,callId,*args):\n         return {\"pong\": args}\n@@ -147,7 +150,13 @@ class RPCView(BaseView):\n         async for msg in ws:\n             print(msg)\n             if msg.type == web.WSMsgType.TEXT:\n-                await rpc(msg.json())\n+                try:\n+                    await rpc(msg.json())\n+                except Exception as ex:\n+                    print(ex,flush=True)\n+                    print(traceback.format_exc(),flush=True)\n+                    await self.services.socket.delete(ws)\n+                    break\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n                 await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Made _send_json and call_ping async functions", "commit": "bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae", "diff": "commit bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:51:47 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 4372e34..3e47bcd 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -129,11 +129,11 @@ class RPCView(BaseView):\n             except Exception as ex:\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n-    async def _send_json(self,obj):\n-        await self.ws.send_text(json.dumps(obj,default=str))\n+        async def _send_json(self,obj):\n+            await self.ws.send_text(json.dumps(obj,default=str))\n \n-    async def call_ping(self,callId,*args):\n-        return {\"pong\": args}\n+        async def call_ping(self,callId,*args):\n+            return {\"pong\": args}\n \n \n     async def get(self):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Use `send_str` instead of `send_text` for JSON serialization", "commit": "010f3b03a0983843c74219484e78c50d595da6e7", "diff": "commit 010f3b03a0983843c74219484e78c50d595da6e7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:52:22 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3e47bcd..f7b61f3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -130,7 +130,7 @@ class RPCView(BaseView):\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n         async def _send_json(self,obj):\n-            await self.ws.send_text(json.dumps(obj,default=str))\n+            await self.ws.send_str(json.dumps(obj,default=str))\n \n         async def call_ping(self,callId,*args):\n             return {\"pong\": args}"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Import json for RPC handling", "commit": "8f502af84eea60b5349fd1980d352f0f8e001502", "diff": "commit 8f502af84eea60b5349fd1980d352f0f8e001502\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:52:56 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex f7b61f3..fc9f2e8 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,7 +1,7 @@\n from aiohttp import web  \n from snek.system.view import BaseView\n import traceback\n-\n+import json\n \n class RPCView(BaseView):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Flush print statements in RPCView", "commit": "10c7232a8f6378eed8f5b4adecca8d582d57a069", "diff": "commit 10c7232a8f6378eed8f5b4adecca8d582d57a069\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:57:12 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex fc9f2e8..b6a3f8c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -148,7 +148,7 @@ class RPCView(BaseView):\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n-            print(msg)\n+            print(msg,flush=True)\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     await rpc(msg.json())"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent error handling from deleting websocket connections", "commit": "88749ce05c7c4e9b5e238d16cb9fa4053f092fc1", "diff": "commit 88749ce05c7c4e9b5e238d16cb9fa4053f092fc1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:39:48 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b6a3f8c..be1a743 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -159,8 +159,10 @@ class RPCView(BaseView):\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n-                await self.services.socket.delete(ws)\n+                pass \n             elif msg.type == web.WSMsgType.CLOSE:\n-                await self.services.socket.delete(ws)\n+                pass \n         print(\"WebSocket connection closed\")\n         return ws"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Add login check for websocket subscriptions", "commit": "2ae2e8450cad47031067f3baae3b09ff521c5c87", "diff": "commit 2ae2e8450cad47031067f3baae3b09ff521c5c87\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:42:12 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex be1a743..eefc4f9 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -141,10 +141,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n+        if self.request.session.get(\"logged_in\") is True:\n+            await self.services.socket.add(ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Add highlight styles to message template", "commit": "495543144d464121af0afab6545a5267ad561a57", "diff": "commit 495543144d464121af0afab6545a5267ad561a57\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:43:01 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 36faaed..1bc473c 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,6 +1,6 @@\n-<style>\n     {{highlight_styles}}\n-</style>\n \n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n@@ -11,4 +11,4 @@\n {% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added highlight styles to web.html", "commit": "cfd3e7881eca77d10d32de2440a9d2b03aeaea96", "diff": "commit cfd3e7881eca77d10d32de2440a9d2b03aeaea96\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:45:41 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3882548..532f75f 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <style>{{highlight_styles}}</style>\n   <script src=\"/media-upload.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n@@ -59,4 +60,4 @@ let installPrompt = null\n         ;\n     </script>\n </body>\n-</html>\n\\ No newline at end of file\n+</html>"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Removed unnecessary highlight styles from message template", "commit": "efe12644eda127170a3d60e086fa31ed940fca6e", "diff": "commit efe12644eda127170a3d60e086fa31ed940fca6e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:47:56 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 1bc473c..20820d3 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,14 +1,5 @@\n-    {{highlight_styles}}\n-\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-{% linkify %}\n-\n-{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n-{% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent default event on change and keyup events", "commit": "7526bcc816ffb759e3708f30167b4d3367955b64", "diff": "commit 7526bcc816ffb759e3708f30167b4d3367955b64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:17:40 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 1ea2c2e..661bf8b 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -27,10 +27,12 @@ class ChatInputElement extends HTMLElement {\n             button.disabled = !message;\n         })\n         this.textBox.addEventListener('change', (e) => {\n+            e.preventDefault()\n             this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n         this.textBox.addEventListener('keyup', (e) => {\n+            e.preventDefault()\n             if (e.key == 'Enter' && !e.shiftKey) {\n                 this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n@@ -47,4 +49,4 @@ class ChatInputElement extends HTMLElement {\n         this.component.appendChild(this.container)\n     }\n }\n-customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\n+customElements.define('chat-input', ChatInputElement);"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent form submission on Shift+Enter in chat input", "commit": "1999a6c8d8dd4fdbd48d5553a1704dfa065275ee", "diff": "commit 1999a6c8d8dd4fdbd48d5553a1704dfa065275ee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:24:50 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 661bf8b..61648c1 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -31,11 +31,14 @@ class ChatInputElement extends HTMLElement {\n             this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n-        this.textBox.addEventListener('keyup', (e) => {\n-            e.preventDefault()\n-            if (e.key == 'Enter' && !e.shiftKey) {\n-                this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n+        this.textBox.addEventListener('keydown', (e) => {\n+\n+            if (e.key == 'Enter') {\n+                if(!e.shiftKey){\n+                    e.preventDefault()\n+                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n+                }\n             }\n         })"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent empty messages from being submitted", "commit": "ae5fffe5e0faf948a22feea0e651e08a0ed559fb", "diff": "commit ae5fffe5e0faf948a22feea0e651e08a0ed559fb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:33:18 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 61648c1..4fd2845 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -36,7 +36,10 @@ class ChatInputElement extends HTMLElement {\n             if (e.key == 'Enter') {\n                 if(!e.shiftKey){\n                     e.preventDefault()\n-                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n+                    const message = e.target.value.trim();\n+                    if(!message)\n+                        return \n+                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: message, bubbles: true }))\n                 e.target.value = ''\n                 }\n             }"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent submitting empty messages", "commit": "663ab415101e5da31fb71e3e9e3b433fbd6c3031", "diff": "commit 663ab415101e5da31fb71e3e9e3b433fbd6c3031\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:36:59 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 4fd2845..6bace32 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -46,6 +46,11 @@ class ChatInputElement extends HTMLElement {\n         })\n \n         this.container.querySelector('button').addEventListener('click', (e) => {\n+            \n+            const message = me.textBox.value.trim();\n+            if(!message){\n+                return \n+            }\n             this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n             setTimeout(()=>{\n                 me.textBox.value = ''"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added query endpoint with security checks", "commit": "3796c7c54767b5de18c5310d20c9dd3c5aafdd0c", "diff": "commit 3796c7c54767b5de18c5310d20c9dd3c5aafdd0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:46:02 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex eefc4f9..94e4d88 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -103,7 +103,15 @@ class RPCView(BaseView):\n             self._require_login()\n             return args\n \n-\n+        async def query(self,*args):\n+            self._require_login()\n+            print(args,flush=True)\n+            query = args[0] \n+            lowercase = query.lower()\n+            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n+                raise Exception(\"Not allowed\")\n+            records = [dict(record) for record in self.services.channel.query(args[0])]\n+            return records"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Made channel query asynchronous", "commit": "f6f99684307249b6650dcbbb3168db1ebfa71e73", "diff": "commit f6f99684307249b6650dcbbb3168db1ebfa71e73\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:47:03 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 94e4d88..531aa40 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -110,7 +110,7 @@ class RPCView(BaseView):\n             lowercase = query.lower()\n             if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) for record in self.services.channel.query(args[0])]\n+            records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Corrected autoescaping in message templates", "commit": "0c68c4e62255a307ecb48cba011ef38ace935eb3", "diff": "commit 0c68c4e62255a307ecb48cba011ef38ace935eb3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 22:07:01 2025 +0100\n\n    Fixed autoescape.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 20820d3..92ac639 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Allow underscores and plus signs in usernames", "commit": "4185bb3a69ac66d7b6614acf76bb5a2f613e0b82", "diff": "commit 4185bb3a69ac66d7b6614acf76bb5a2f613e0b82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 00:01:23 2025 +0100\n\n    Changed validation.\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex f611d6a..2910a6d 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -8,7 +8,7 @@ class UserModel(BaseModel):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     nick = ModelField(\n         name=\"nick\","}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added emoji support to templates", "commit": "928969b8b6266298317ea4f7ca3e6b2cfbd42e82", "diff": "commit 928969b8b6266298317ea4f7ca3e6b2cfbd42e82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 05:41:03 2025 +0100\n\n    Added emoji's\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex a2a0ccb..98207fd 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -25,6 +25,7 @@ dependencies = [\n     \"aiohttp-session\",\n     \"cryptography\",\n     \"requests\",\n-    \"asyncssh\"\n+    \"asyncssh\",\n+    \"emoji\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c2c3651..137abf0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -16,7 +16,7 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n-from snek.system.template import LinkifyExtension, PythonExtension\n+from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n@@ -54,6 +54,7 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n+        self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n         self.cache = Cache(self)\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 69222b6..ed153f0 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,6 +1,11 @@\n from types import SimpleNamespace\n from bs4 import BeautifulSoup\n import re \n+import emoji\n+\n+from jinja2 import TemplateSyntaxError, nodes\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n \n \n \n@@ -33,9 +38,24 @@ def linkify_https(text):\n     return set_link_target_blank(str(soup))\n \n \n-from jinja2 import TemplateSyntaxError, nodes\n-from jinja2.ext import Extension\n-from jinja2.nodes import Const\n+class EmojiExtension(Extension):\n+    tags = {\"emoji\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return emoji.emojize(caller(),language='alias')\n+\n \n \n class LinkifyExtension(Extension):\n@@ -98,4 +118,4 @@ class PythonExtension(Extension):\n                 to_write.append(text)\n             exec(source)\n             return \"\".join(to_write)\n-        return str(fn(caller()))\n\\ No newline at end of file\n+        return str(fn(caller()))\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 92ac639..39ea7e8 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "refactor: Removed unnecessary timezone handling", "commit": "feeb94c9cf08ebee6d42165988b1d51030df4c33", "diff": "commit feeb94c9cf08ebee6d42165988b1d51030df4c33\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 17:39:57 2025 +0100\n\n    Removed Z\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 67a12c2..1272281 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -53,8 +53,6 @@ class MessageListElement extends HTMLElement {\n         return `just now`\n     }\n     timeDescription(isoDate) {\n-        if (!isoDate.endsWith(\"Z\"))\n-            isoDate += \"Z\"\n         const date = new Date(isoDate)\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n@@ -170,4 +168,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\n+customElements.define('message-list', MessageListElement);"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added message highlighting", "commit": "e0ed4491b414c51b54e4c3ebd10cbebb46a903c6", "diff": "commit e0ed4491b414c51b54e4c3ebd10cbebb46a903c6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:35:27 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 39ea7e8..42ef9fa 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added basic syntax highlighting stylesheet", "commit": "98d89dbc5f45a61ab6335e38d6e4a1df39bcc621", "diff": "commit 98d89dbc5f45a61ab6335e38d6e4a1df39bcc621\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:38:35 2025 +0100\n\n    highlight\n\ndiff --git a/src/snek/static/highlight.css b/src/snek/static/highlight.css\nnew file mode 100644\nindex 0000000..8e6fbf1\n--- /dev/null\n+++ b/src/snek/static/highlight.css\n@@ -0,0 +1,74 @@\n+pre { line-height: 125%; }\n+td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }\n+span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Simplified message template structure", "commit": "a06e3f404a15d8115fa65ba8533ff7774baa0beb", "diff": "commit a06e3f404a15d8115fa65ba8533ff7774baa0beb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:41:38 2025 +0100\n\n    highlight\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 42ef9fa..bcff7fb 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1 @@\n-    <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-    </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-02", "line": "feat: Add pywebpush dependency and user data to app\n\nfix: Play audio only when not the same user", "commit": "99fc9118b37f8564cd6e211d3d77ef997592f361", "diff": "commit 99fc9118b37f8564cd6e211d3d77ef997592f361\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 2 23:14:00 2025 +0100\n\n    Fixed audio notification\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 98207fd..423a69d 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -26,6 +26,7 @@ dependencies = [\n     \"cryptography\",\n     \"requests\",\n     \"asyncssh\",\n-    \"emoji\"\n+    \"emoji\",\n+    \"pywebpush\"\n ]\n \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 44853f1..46df3df 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,81 +1,5 @@\n \n \n-    uid = null \n-    author = null\n-    avatar = null \n-    text = null \n-    time = null\n-    constructor(uid,avatar,author,text,time){\n-        this.uid = uid \n-        this.avatar = avatar \n-        this.author = author \n-        this.text = text \n-        this.time = time \n-    }\n-    \n-    get links() {\n-        if(!this.text)\n-            return []\n-        let result = []\n-        for(let part in this.text.split(/[,; ]/)){\n-            if(part.startsWith(\"http\") || part.startsWith(\"www.\") || part.indexOf(\".com\") || part.indexOf(\".net\") || part.indexOf(\".io\") || part.indexOf(\".nl\")){\n-                result.push(part)\n-\n-            }\n-        }\n-        return result\n-    }\n-    get mentions() {\n-        if(!this.text)\n-            return []\n-        let result = []\n-        for(let part in this.text.split(/[,; ]/)){\n-            if(part.startsWith(\"@\")){\n-                result.push(part)\n-\n-            }\n-        }\n-        return result \n-    }\n-\n-\n-class Messages {\n-\n-\n-\n-}\n-\n-\n-\n-\n-class Room {\n-    name = null\n-    messages = []\n-    constructor(name) {\n-        this.name = name\n-    }\n-    setMessages(list) {\n-\n-    }\n-\n-\n-}\n-\n-\n-class InlineAppElement extends HTMLElement {\n-\n-    constructor() {\n-    }\n-\n-}\n-\n-class Page {\n-    elements = []\n-\n-}\n \n class RESTClient {\n     debug = false\n@@ -378,7 +302,8 @@ class App extends EventHandler {\n     rest = rest\n     ws = null\n     rpc = null\n-    audio = null \n+    audio = null\n+    user = {}\n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n@@ -389,6 +314,7 @@ class App extends EventHandler {\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n+        this.user = await this.rpc.getUser(null)\n     }\n     playSound(index){\n         this.audio.play(index)\n@@ -417,4 +343,4 @@ class App extends EventHandler {\n \n }\n \n-const app = new App()\n\\ No newline at end of file\n+const app = new App()\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 58845b4..ce02fd3 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -6,10 +6,12 @@ class ChatWindowElement extends HTMLElement {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n-        \n+        this.app = app  \n         this.shadowRoot.appendChild(this.component);\n     }\n-\n+    get user() {\n+        return this.app.user \n+    }\n     async connectedCallback() {\n         const link = document.createElement('link')\n         link.rel = 'stylesheet'\n@@ -61,7 +63,8 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            app.playSound(0)\n+            if(me.user.uid != message.detail.user_uid)\n+                app.playSound(0)\n             message.detail.element.scrollIntoView()\n          \n     })\n@@ -72,4 +75,4 @@ class ChatWindowElement extends HTMLElement {\n \n }\n \n-customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\n+customElements.define('chat-window', ChatWindowElement);"}
{"repo": ".", "date": "2025-02-02", "line": "fix: Resolve issue with user data loading and notification handling", "commit": "7d750db1f8235c8231699c2da39c1075ac678841", "diff": "commit 7d750db1f8235c8231699c2da39c1075ac678841\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 2 23:21:43 2025 +0100\n\n    Fixed own notification issue.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 46df3df..52c25a4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -298,7 +298,6 @@ class NotificationAudio {\n }\n \n class App extends EventHandler {\n-    rooms = []\n     rest = rest\n     ws = null\n     rpc = null\n@@ -306,7 +305,6 @@ class App extends EventHandler {\n     user = {}\n     constructor() {\n         super()\n-        this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n         this.rpc = this.ws.client\n         const me = this\n@@ -314,7 +312,10 @@ class App extends EventHandler {\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n-        this.user = await this.rpc.getUser(null)\n+\n+        this.rpc.getUser(null).then(user=>{\n+            me.user = user\n+        })\n     }\n     playSound(index){\n         this.audio.play(index)"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Improved code display with word wrapping", "commit": "3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5", "diff": "commit 3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:07:33 2025 +0100\n\n    Wrapped highlight.css\n\ndiff --git a/src/snek/static/highlight.css b/src/snek/static/highlight.css\nindex 8e6fbf1..455fa19 100644\n--- a/src/snek/static/highlight.css\n+++ b/src/snek/static/highlight.css\n@@ -1,4 +1,6 @@\n-pre { line-height: 125%; }\n+pre { line-height: 125%;  white-space: pre-wrap; \n+  word-break: break-word; \n+  overflow-wrap: break-word; }\n td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }\n span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in base CSS", "commit": "23c8ebca73ac49c826434d40fd1e1fd2e3435957", "diff": "commit 23c8ebca73ac49c826434d40fd1e1fd2e3435957\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:08:17 2025 +0100\n\n    Updated CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 46b9fef..1d98245 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -2,6 +2,9 @@\n   margin: 0;\n   box-sizing: border-box;\n+ white-space: pre-wrap; \n+  word-break: break-word; \n+  overflow-wrap: break-word;\n }\n \n .gallery {"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in message content", "commit": "38a24e9a12355f93776c9aef0b9caa5afb075531", "diff": "commit 38a24e9a12355f93776c9aef0b9caa5afb075531\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:10:09 2025 +0100\n\n    Updated CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 1d98245..aa3615b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -2,9 +2,6 @@\n   margin: 0;\n   box-sizing: border-box;\n- white-space: pre-wrap; \n-  word-break: break-word; \n-  overflow-wrap: break-word;\n }\n \n .gallery {\n@@ -183,6 +180,10 @@ message-list {\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n+   white-space: pre-wrap;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+\n }\n \n .chat-messages .message .message-content .time {"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Reduced padding on message list", "commit": "83cc0f613708ed27b928ba45b330c984d82dd546", "diff": "commit 83cc0f613708ed27b928ba45b330c984d82dd546\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:24:09 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa3615b..44b3c5c 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -147,7 +147,7 @@ message-list {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0px;\n-  padding: 5px;\n   border-radius: 8px;"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added padding and hidden avatar in message list", "commit": "079187e1b460e5554bfec8b9658b5059cc3d51c6", "diff": "commit 079187e1b460e5554bfec8b9658b5059cc3d51c6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:27:21 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 44b3c5c..d8f67e7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -147,7 +147,7 @@ message-list {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0px;\n+  padding: 5px;\n   border-radius: 8px;\n@@ -248,6 +248,7 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n+      display: none;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Hide avatar on message list", "commit": "f4a5536dcf1e27a7e8319488f8f39a8acfb818a2", "diff": "commit f4a5536dcf1e27a7e8319488f8f39a8acfb818a2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:28:48 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex d8f67e7..0599a55 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -248,7 +248,9 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n-      display: none;\n+      height: 0;\n+      padding: 0;\n+      margin: 0;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "revert: Restored avatar visibility", "commit": "fe707dca4ea0bc2ecaedcda292f1ae636fce2b93", "diff": "commit fe707dca4ea0bc2ecaedcda292f1ae636fce2b93\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:30:43 2025 +0100\n\n    Back to default.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0599a55..aa3615b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -248,9 +248,6 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n-      height: 0;\n-      padding: 0;\n-      margin: 0;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added upload button and basic upload functionality", "commit": "b48a901e3385617d36511e251c4e7c62498e23bc", "diff": "commit b48a901e3385617d36511e251c4e7c62498e23bc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 20:45:29 2025 +0100\n\n    Non working upload button.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 423a69d..ed36d70 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -6,7 +6,7 @@ build-backend = \"setuptools.build_meta\"\n name = \"Snek\"\n version = \"1.0.0\"\n readme = \"README.md\"\n-license = { file = \"LICENSE\", content-type=\"text/markdown\" }\n description = \"Snek Chat Application by Molodetz\"\n authors = [\n     { name = \"retoor\", email = \"retoor@molodetz.nl\" }\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 52c25a4..ada9654 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,347 +1,309 @@\n \n \n+\n \n class RESTClient {\n-    debug = false\n+    debug = false;\n \n-    async get(url, params) {\n-        params = params ? params : {}\n+    async get(url, params = {}) {\n         const encodedParams = new URLSearchParams(params);\n-        if (encodedParams)\n-            url += '?' + encodedParams\n+        if (encodedParams) url += '?' + encodedParams;\n         const response = await fetch(url, {\n             method: 'GET',\n             headers: {\n-                'Content-Type': 'application/json'\n-            }\n+                'Content-Type': 'application/json',\n+            },\n         });\n-        const result = await response.json()\n+        const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url: url, params: params, result: result })\n+            console.debug({ url, params, result });\n         }\n-        return result\n+        return result;\n     }\n+\n     async post(url, data) {\n         const response = await fetch(url, {\n             method: 'POST',\n             headers: {\n-                'Content-Type': 'application/json'\n+                'Content-Type': 'application/json',\n             },\n-            body: JSON.stringify(data)\n+            body: JSON.stringify(data),\n         });\n \n-        const result = await response.json()\n+        const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url: url, params: params, result: result })\n+            console.debug({ url, data, result });\n         }\n-        return result\n+        return result;\n     }\n }\n-const rest = new RESTClient()\n \n class EventHandler {\n-\n     constructor() {\n-        this.subscribers = {}\n+        this.subscribers = {};\n     }\n+\n     addEventListener(type, handler) {\n-        if (!this.subscribers[type])\n-            this.subscribers[type] = []\n-        this.subscribers[type].push(handler)\n+        if (!this.subscribers[type]) this.subscribers[type] = [];\n+        this.subscribers[type].push(handler);\n     }\n+\n     emit(type, ...data) {\n-        if (this.subscribers[type])\n-            this.subscribers[type].forEach(handler => handler(...data))\n+        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n     }\n-\n }\n \n class Chat extends EventHandler {\n-\n     constructor() {\n-        super()\n-        this._socket = null\n-        this._wait_connect = null\n-        this._promises = {}\n+        super();\n+        this._socket = null;\n+        this._waitConnect = null;\n+        this._promises = {};\n     }\n-    connect() {\n-        if (this._wait_connect)\n-            return this._wait_connect\n \n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n-            me._wait_connect = resolve\n-            console.debug(\"Connecting..\")\n+    connect() {\n+        if (this._waitConnect) {\n+            return this._waitConnect;\n+        }\n+        return new Promise((resolve) => {\n+            this._waitConnect = resolve;\n+            console.debug(\"Connecting..\");\n \n             try {\n-                me._socket = new WebSocket(me._url)\n-            }catch(e){\n-                console.warning(e)\n-                setTimeout(()=>{\n-                    me.ensureConnection()\n-                },1000)\n-            }\n-\n-            me._socket.onconnect = () => {\n-                me._connected()\n-                me._wait_socket(me)\n+                this._socket = new WebSocket(this._url);\n+            } catch (e) {\n+                console.warn(e);\n+                setTimeout(() => {\n+                    this.ensureConnection();\n+                }, 1000);\n             }\n-        })\n \n+            this._socket.onconnect = () => {\n+                this._connected();\n+                this._waitSocket();\n+            };\n+        });\n     }\n+\n     generateUniqueId() {\n+        return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n+\n     call(method, ...args) {\n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n+        return new Promise((resolve, reject) => {\n             try {\n-                const command = { method: method, args: args, message_id: me.generateUniqueId() }\n-                me._promises[command.message_id] = resolve\n-                await me._socket.send(JSON.stringify(command))\n-\n+                const command = { method, args, message_id: this.generateUniqueId() };\n+                this._promises[command.message_id] = resolve;\n+                this._socket.send(JSON.stringify(command));\n             } catch (e) {\n-                reject(e)\n+                reject(e);\n             }\n-        })\n+        });\n     }\n+\n     _connected() {\n-        const me = this\n         this._socket.onmessage = (event) => {\n-            const message = JSON.parse(event.data)\n-            if (message.message_id && me._promises[message.message_id]) {\n-                me._promises[message.message_id](message)\n-                delete me._promises[message.message_id]\n+            const message = JSON.parse(event.data);\n+            if (message.message_id && this._promises[message.message_id]) {\n+                this._promises[message.message_id](message);\n+                delete this._promises[message.message_id];\n             } else {\n-                me.emit(\"message\", me, message)\n+                this.emit(\"message\", message);\n             }\n-        }\n-        this._socket.onclose = (event) => {\n-            me._wait_socket = null\n-            me._socket = null\n-            me.emit('close', me)\n-        }\n+        };\n+        this._socket.onclose = () => {\n+            this._waitSocket = null;\n+            this._socket = null;\n+            this.emit('close');\n+        };\n     }\n \n     async privmsg(room, text) {\n         await rest.post(\"/api/privmsg\", {\n-            room: room,\n-            text: text\n-        })\n+            room,\n+            text,\n+        });\n     }\n-\n }\n \n class Socket extends EventHandler {\n-    ws = null\n-    isConnected = null\n-    isConnecting = null\n-    url = null\n-    connectPromises = []\n-    ensureTimer = null \n+    ws = null;\n+    isConnected = null;\n+    isConnecting = null;\n+    url = null;\n+    connectPromises = [];\n+    ensureTimer = null;\n+\n     constructor() {\n-        super()\n-        this.ensureConnection()\n+        super();\n+        this.ensureConnection();\n     }\n+\n     _camelToSnake(str) {\n-        return str\n-            .replace(/([a-z])([A-Z])/g, '$1_$2')\n-            .toLowerCase();\n+        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n     }\n+\n     get client() {\n-        const me = this\n-        const proxy = new Proxy(\n-            {},\n-            {\n-                get(target, prop) {\n-                    return (...args) => {\n-                        let functionName = me._camelToSnake(prop)\n-                        return me.call(functionName, ...args);\n-                    };\n-                },\n-            }\n-        );\n-        return proxy\n+        const me = this;\n+        return new Proxy({}, {\n+            get(_, prop) {\n+                return (...args) => {\n+                    const functionName = me._camelToSnake(prop);\n+                    return me.call(functionName, ...args);\n+                };\n+            },\n+        });\n     }\n+\n     ensureConnection() {\n-        if(this.ensureTimer)\n-            return this.connect()\n-        const me  = this \n-        this.ensureTimer = setInterval(()=>{\n-            if (me.isConnecting)\n-                me.isConnecting = false\n-            me.connect()\n-        },5000)\n-        return this.connect()\n+        if (this.ensureTimer) {\n+            return this.connect();\n+        }\n+        this.ensureTimer = setInterval(() => {\n+            if (this.isConnecting) this.isConnecting = false;\n+            this.connect();\n+        }, 5000);\n+        return this.connect();\n     }\n+\n     generateUniqueId() {\n         return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n+\n     connect() {\n-        const me = this\n-        if (!this.isConnected && !this.isConnecting) {\n-            this.isConnecting = true\n-        } else if (this.isConnecting) {\n-            return new Promise((resolve, reject) => {\n-                me.connectPromises.push(resolve)\n-            })\n-        } else if (this.isConnected) {\n-            return new Promise((resolve, reject) => {\n-                resolve(me)\n-            })\n+        if (this.isConnected || this.isConnecting) {\n+            return new Promise((resolve) => {\n+                this.connectPromises.push(resolve);\n+                if (!this.isConnected) resolve(this);\n+            });\n         }\n-        return new Promise((resolve, reject) => {\n-            me.connectPromises.push(resolve)\n-            console.debug(\"Connecting..\")\n-            \n-            const ws = new WebSocket(this.url)\n-               \n-                ws.onopen = (event) => {\n-                me.ws = ws\n-                me.isConnected = true\n-                me.isConnecting = false\n+        this.isConnecting = true;\n+        return new Promise((resolve) => {\n+            this.connectPromises.push(resolve);\n+            console.debug(\"Connecting..\");\n+\n+            const ws = new WebSocket(this.url);\n+            ws.onopen = () => {\n+                this.ws = ws;\n+                this.isConnected = true;\n+                this.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    me.onData(JSON.parse(event.data))\n-                }\n-                ws.onclose = (event) => {\n-                    me.onClose()\n-\n-                }\n-                ws.onerror = (event)=>{\n-                    me.onClose()\n-                }\n-                me.connectPromises.forEach(resolve => {\n-                    resolve(me)\n-                })\n-            }\n-        })\n+                    this.onData(JSON.parse(event.data));\n+                };\n+                ws.onclose = () => {\n+                    this.onClose();\n+                };\n+                ws.onerror = () => {\n+                    this.onClose();\n+                };\n+                this.connectPromises.forEach(resolver => resolver(this));\n+            };\n+        });\n     }\n+\n     onData(data) {\n-        if(data.success != undefined && !data.success){\n-            console.error(data)\n+        if (data.success !== undefined && !data.success) {\n+            console.error(data);\n         }\n-\n         if (data.callId) {\n-            this.emit(data.callId, data.data)\n+            this.emit(data.callId, data.data);\n         }\n         if (data.channel_uid) {\n-            this.emit(data.channel_uid, data.data)\n-            this.emit(\"channel-message\", data)\n+            this.emit(data.channel_uid, data.data);\n+            this.emit(\"channel-message\", data);\n         }\n-\n     }\n+\n     async sendJson(data) {\n-        return await this.connect().then((api) => {\n-            api.ws.send(JSON.stringify(data))\n-        })\n+        await this.connect().then(api => {\n+            api.ws.send(JSON.stringify(data));\n+        });\n     }\n \n     async call(method, ...args) {\n         const call = {\n             callId: this.generateUniqueId(),\n-            method: method,\n-            args: args\n-        }\n-\n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n-            me.addEventListener(call.callId, (data) => {\n-                resolve(data)\n-            })\n-            await me.sendJson(call)\n-\n-\n-        })\n+            method,\n+            args,\n+        };\n+        return new Promise((resolve) => {\n+            this.addEventListener(call.callId, data => resolve(data));\n+            this.sendJson(call);\n+        });\n     }\n+\n     onClose() {\n-        console.info(\"Connection lost. Reconnecting.\")\n-        this.isConnected = false\n-        this.isConnecting = false\n+        console.info(\"Connection lost. Reconnecting.\");\n+        this.isConnected = false;\n+        this.isConnecting = false;\n         this.ensureConnection().then(() => {\n-            console.info(\"Reconnected.\")\n-        })\n+            console.info(\"Reconnected.\");\n+        });\n     }\n-\n }\n \n class NotificationAudio {\n-    constructor(timeout){\n-        if(!timeout)\n-            timeout = 500\n-        this.schedule = new Schedule(timeout)\n+    constructor(timeout = 500) {\n+        this.schedule = new Schedule(timeout);\n     }\n-    sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n-    play(soundIndex) {\n-        this.schedule.delay(() => {\n-            \n-        \n-\n-        if (!soundIndex)\n-            soundIndex = 0\n \n-        const player = new Audio(this.sounds[soundIndex]);\n+    sounds = [\"/audio/soundfx.d_beep3.mp3\"];\n \n-        player.play()\n-            .then(() => {\n-                console.debug(\"Gave sound notification\")\n-            })\n-            .catch((error) => {\n-                console.error(\"Notification failed:\", error);\n-            });\n-        })\n+    play(soundIndex = 0) {\n+        this.schedule.delay(() => {\n+            new Audio(this.sounds[soundIndex]).play()\n+                .then(() => {\n+                    console.debug(\"Gave sound notification\");\n+                })\n+                .catch(error => {\n+                    console.error(\"Notification failed:\", error);\n+                });\n+        });\n     }\n }\n \n class App extends EventHandler {\n-    rest = rest\n-    ws = null\n-    rpc = null\n-    audio = null\n-    user = {}\n+    rest = new RESTClient();\n+    ws = null;\n+    rpc = null;\n+    audio = null;\n+    user = {};\n+\n     constructor() {\n-        super()\n-        this.ws = new Socket()\n-        this.rpc = this.ws.client\n-        const me = this\n-        this.audio = new NotificationAudio(500)\n+        super();\n+        this.ws = new Socket();\n+        this.rpc = this.ws.client;\n+        this.audio = new NotificationAudio(500);\n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid, data)\n-        })\n+            this.emit(data.channel_uid, data);\n+        });\n \n-        this.rpc.getUser(null).then(user=>{\n-            me.user = user\n-        })\n+        this.rpc.getUser(null).then(user => {\n+            this.user = user;\n+        });\n     }\n-    playSound(index){\n-        this.audio.play(index)\n+\n+    playSound(index) {\n+        this.audio.play(index);\n     }\n-    async benchMark(times, message) {\n-        if (!times)\n-            times = 100\n-        if (!message)\n-            message = \"Benchmark Message\"\n-        let promises = []\n-        const me = this\n+\n+    async benchMark(times = 100, message = \"Benchmark Message\") {\n+        const promises = [];\n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n-                    me.rpc.sendMessage(channel.uid, `${message} ${i}`).then(data => {\n-\n-                    })\n-                })\n-            }))\n-\n+                    this.rpc.sendMessage(channel.uid, `${message} ${i}`);\n+                });\n+            }));\n         }\n-\n     }\n-\n-\n }\n \n-const app = new App()\n+const app = new App();\n\\ No newline at end of file\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa3615b..2393a62 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,7 +209,7 @@ message-list {\n   resize: none;\n }\n \n-.chat-input button {\n+.chat-input upload-button {\n   color: white;\n   border: none;\n@@ -240,11 +240,16 @@ message-list {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n+  white-space: pre-wrap; \n   hyphens: auto;\n     img {\n       max-width: 90%;\n       border-radius: 20px;\n     }\n+    {\n+        padding: 0;\n+        margin: 0;\n+    }\n   }\n   .avatar {\n       opacity: 0;\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 6bace32..6a9353c 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -1,63 +1,59 @@\n \n+\n+\n \n class ChatInputElement extends HTMLElement {\n-    constructor() {\n-        super();\n-        this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div');\n-        this.shadowRoot.appendChild(this.component);\n-    }\n-    connectedCallback() {\n-        const me = this\n-        const link = document.createElement(\"link\")\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.container = document.createElement('div')\n-        this.container.classList.add(\"chat-input\")\n-        this.container.innerHTML = `\n-            <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-            <button>Send</button>\n-        `;\n-        this.textBox = this.container.querySelector('textarea')\n-        this.textBox.addEventListener('input', (e) => {\n-            this.dispatchEvent(new CustomEvent(\"input\", { detail: e.target.value, bubbles: true }))\n-            const message = e.target.value;\n-            const button = this.container.querySelector('button');\n-            button.disabled = !message;\n-        })\n-        this.textBox.addEventListener('change', (e) => {\n-            e.preventDefault()\n-            this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n-            console.error(e.target.value)\n-        })\n-        this.textBox.addEventListener('keydown', (e) => {\n-\n-            if (e.key == 'Enter') {\n-                if(!e.shiftKey){\n-                    e.preventDefault()\n-                    const message = e.target.value.trim();\n-                    if(!message)\n-                        return \n-                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: message, bubbles: true }))\n-                e.target.value = ''\n-                }\n-            }\n-        })\n-\n-        this.container.querySelector('button').addEventListener('click', (e) => {\n-            \n-            const message = me.textBox.value.trim();\n-            if(!message){\n-                return \n-            }\n-            this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n-            setTimeout(()=>{\n-                me.textBox.value = ''\n-                me.textBox.focus()\n-            },200)\n-        })\n-        this.component.appendChild(this.container)\n-    }\n+  \n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.component = document.createElement('div');\n+    this.shadowRoot.appendChild(this.component);\n+  }\n+\n+  connectedCallback() {\n+    const link = document.createElement('link');\n+    link.rel = 'stylesheet';\n+    link.href = '/base.css';\n+    this.component.appendChild(link);\n+\n+    this.container = document.createElement('div');\n+    this.container.classList.add('chat-input');\n+    this.container.innerHTML = `\n+      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+      <upload-button></upload-button>\n+    `;\n+    this.textBox = this.container.querySelector('textarea');\n+\n+    this.textBox.addEventListener('input', (e) => {\n+      this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n+      const message = e.target.value;\n+      const button = this.container.querySelector('button');\n+      button.disabled = !message;\n+    });\n+\n+    this.textBox.addEventListener('change', (e) => {\n+      e.preventDefault();\n+      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+      console.error(e.target.value);\n+    });\n+\n+    this.textBox.addEventListener('keydown', (e) => {\n+      if (e.key === 'Enter' && !e.shiftKey) {\n+        e.preventDefault();\n+        const message = e.target.value.trim();\n+        if (!message) return;\n+        this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));\n+        e.target.value = '';\n+      }\n+    });\n+\n+    this.component.appendChild(this.container);\n+  }\n }\n-customElements.define('chat-input', ChatInputElement);\n+\n+customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex ce02fd3..2c2973a 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -1,78 +1,78 @@\n \n+\n+\n \n class ChatWindowElement extends HTMLElement {\n-    receivedHistory = false\n+    receivedHistory = false;\n+\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n-        this.app = app  \n+        this.app = app;\n         this.shadowRoot.appendChild(this.component);\n     }\n+\n     get user() {\n-        return this.app.user \n+        return this.app.user;\n     }\n+\n     async connectedCallback() {\n-        const link = document.createElement('link')\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.component.classList.add(\"chat-area\")\n-        this.container = document.createElement(\"section\")\n-        this.container.classList.add(\"chat-area\")\n-        this.container.classList.add(\"chat-window\")\n-        \n-        const chatHeader = document.createElement(\"div\")\n-        chatHeader.classList.add(\"chat-header\")\n+        const link = document.createElement('link');\n+        link.rel = 'stylesheet';\n+        link.href = '/base.css';\n+        this.component.appendChild(link);\n+        this.component.classList.add(\"chat-area\");\n         \n+        this.container = document.createElement(\"section\");\n+        this.container.classList.add(\"chat-area\", \"chat-window\");\n \n-        \n-        \n-        const chatTitle = document.createElement('h2')\n-        chatTitle.classList.add(\"chat-title\")\n-        chatTitle.innerText = \"Loading...\"\n-        chatHeader.appendChild(chatTitle)\n-        this.container.appendChild(chatHeader)\n-        const channels = await app.rpc.getChannels()\n-        const channel = channels[0]\n-        chatTitle.innerText = channel.name \n-        const channelElement = document.createElement('message-list')\n-        channelElement.setAttribute(\"channel\", channel.uid)\n-        this.container.appendChild(channelElement)\n-        const chatInput = document.createElement('chat-input')\n-        \n-        chatInput.addEventListener(\"submit\",(e)=>{\n-            app.rpc.sendMessage(channel.uid,e.detail)\n-        })\n-        this.container.appendChild(chatInput)\n-\n-        this.component.appendChild(this.container)\n-        const messages = await app.rpc.getMessages(channel.uid)\n-        messages.forEach(message=>{\n-            if(!message['user_nick'])\n-                return\n-            channelElement.addMessage(message)\n-        })\n-        const me = this\n-        channelElement.addEventListener(\"message\",(message)=>{\n-            if(me.user.uid != message.detail.user_uid)\n-                app.playSound(0)\n-            message.detail.element.scrollIntoView()\n-         \n-    })\n+        const chatHeader = document.createElement(\"div\");\n+        chatHeader.classList.add(\"chat-header\");\n \n-        \n-    }\n+        const chatTitle = document.createElement('h2');\n+        chatTitle.classList.add(\"chat-title\");\n+        chatTitle.innerText = \"Loading...\";\n+        chatHeader.appendChild(chatTitle);\n+        this.container.appendChild(chatHeader);\n+\n+        const channels = await app.rpc.getChannels();\n+        const channel = channels[0];\n+        chatTitle.innerText = channel.name;\n \n+        const channelElement = document.createElement('message-list');\n+        channelElement.setAttribute(\"channel\", channel.uid);\n+        this.container.appendChild(channelElement);\n \n+        const chatInput = document.createElement('chat-input');\n+        chatInput.addEventListener(\"submit\", (e) => {\n+            app.rpc.sendMessage(channel.uid, e.detail);\n+        });\n+        this.container.appendChild(chatInput);\n+\n+        this.component.appendChild(this.container);\n+\n+        const messages = await app.rpc.getMessages(channel.uid);\n+        messages.forEach(message => {\n+            if (!message['user_nick']) return;\n+            channelElement.addMessage(message);\n+        });\n+\n+        const me = this;\n+        channelElement.addEventListener(\"message\", (message) => {\n+            if (me.user.uid !== message.detail.user_uid) app.playSound(0);\n+            message.detail.element.scrollIntoView();\n+        });\n+    }\n }\n \n-customElements.define('chat-window', ChatWindowElement);\n+customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 5eec215..948db17 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -1,30 +1,33 @@\n+\n+\n+\n+\n \n \n class FancyButton extends HTMLElement {\n-    url = null\n-    type=\"button\"\n-    value = null\n-    constructor(){\n-        super()\n-        this.attachShadow({mode:'open'})\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.url = null;\n+        this.type = \"button\";\n+        this.value = null;\n     }\n \n     connectedCallback() {\n+        this.container = document.createElement('span');\n+        let size = this.getAttribute('size');\n+        console.info({ GG: size });\n+        size = size === 'auto' ? '1%' : '33%';\n \n-        this.container = document.createElement('span')\n-        let size = this.getAttribute('size')\n-        console.info({GG:size})\n-        if(size == 'auto'){\n-           size = '1%' \n-        }else{\n-            size = '33%'\n-        }\n-        this.styleElement = document.createElement(\"style\")\n+        this.styleElement = document.createElement(\"style\");\n         this.styleElement.innerHTML = `\n         :root {\n-        width:100%;\n+            width: 100%;\n             --width: 100%;\n-    }\n+        }\n         button {\n             width: var(--width);\n             min-width: ${size};\n@@ -37,32 +40,31 @@ class FancyButton extends HTMLElement {\n             font-weight: bold;\n             cursor: pointer;\n             transition: background-color 0.3s;\n-\n-    }\n+        }\n         button:hover {\n-  }\n-    `\n-    this.container.appendChild(this.styleElement)\n-        this.buttonElement = document.createElement('button')\n-        this.container.appendChild(this.buttonElement)\n-        this.shadowRoot.appendChild(this.container)\n-    \n+        }\n+        `;\n+\n+        this.container.appendChild(this.styleElement);\n+        this.buttonElement = document.createElement('button');\n+        this.container.appendChild(this.buttonElement);\n+        this.shadowRoot.appendChild(this.container);\n+\n         this.url = this.getAttribute('url');\n-        this.value = this.getAttribute('value')\n-        const me = this \n-        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n-        this.buttonElement.addEventListener(\"click\",()=>{\n-            if(me.url == \"/back\" || me.url == \"/back/\"){\n-                window.history.back()\n-            }else if(me.url){\n-                window.location = me.url\n+        this.value = this.getAttribute('value');\n+        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")));\n+        this.buttonElement.addEventListener(\"click\", () => {\n+            if (this.url === \"/back\" || this.url === \"/back/\") {\n+                window.history.back();\n+            } else if (this.url) {\n+                window.location = this.url;\n             }\n-        })\n+        });\n     }\n }\n \n-customElements.define(\"fancy-button\",FancyButton)\n+customElements.define(\"fancy-button\", FancyButton);\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex e775bf4..730d70a 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -1,346 +1,362 @@\n+\n+\n+\n \n class GenericField extends HTMLElement {\n-  form = null\n-  field = null \n-  inputElement = null\n-  footerElement = null \n-  action = null \n-  container = null\n-  styleElement = null\n-  name = null\n-    get value() {\n-      return this.inputElement.value\n-    }\n-    get type() {\n+  form = null;\n+  field = null;\n+  inputElement = null;\n+  footerElement = null;\n+  action = null;\n+  container = null;\n+  styleElement = null;\n+  name = null;\n \n-        return this.field.tag \n-    }\n-    set value(val) {\n-      val = val == null ? '' : val \n-      this.inputElement.value = val \n-      this.inputElement.setAttribute(\"value\", val)\n-    }\n-    setInvalid(){\n-      this.inputElement.classList.add(\"error\")\n-      this.inputElement.classList.remove(\"valid\")\n-    }\n-    setErrors(errors){\n-      if(errors.length)\n-        this.inputElement.setAttribute(\"title\", errors[0])\n-      else\n-       this.inputElement.setAttribute(\"title\",\"\")\n-    }\n-    setValid(){\n-      this.inputElement.classList.remove(\"error\")\n-      this.inputElement.classList.add(\"valid\")\n-    }\n-    constructor() {\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.container = document.createElement('div')\n-        this.styleElement = document.createElement('style')\n-        this.styleElement.innerHTML = `\n-\n-            h1 {\n-              font-size: 2em;\n-              margin-bottom: 20px;\n-              margin-top: 0px;\n-            }\n+  get value() {\n+    return this.inputElement.value;\n+  }\n \n-            input {\n-                width: 90%;\n-                padding: 10px;\n-                margin: 10px 0;\n-                border-radius: 5px;\n-                font-size: 1em;\n-            }\n+  get type() {\n+    return this.field.tag;\n+  }\n \n-            button {\n-                width: 50%;\n-                padding: 10px;\n-                border: none;\n-               float: right;\n-                margin-top: 10px;\n-                margin-left: 10px;\n-                margin-right: 10px;\n-                border-radius: 5px;\n-                color: white;\n-                font-size: 1em;\n-                font-weight: bold;\n-                cursor: pointer;\n-                transition: background-color 0.3s;\n-                clear: both;\n-            }\n+  set value(val) {\n+    val = val ?? '';\n+    this.inputElement.value = val;\n+    this.inputElement.setAttribute(\"value\", val);\n+  }\n \n-            button:hover {\n-            }\n+  setInvalid() {\n+    this.inputElement.classList.add(\"error\");\n+    this.inputElement.classList.remove(\"valid\");\n+  }\n \n-            a {\n-                text-decoration: none;\n-                display: block;\n-                margin-top: 15px;\n-                font-size: 0.9em;\n-                transition: color 0.3s;\n-            }\n+  setErrors(errors) {\n+    const errorText = errors.length ? errors[0] : \"\";\n+    this.inputElement.setAttribute(\"title\", errorText);\n+  }\n \n-            a:hover {\n-            }\n-            .valid {\n-                border: 1px solid green;\n-                color:green;\n-                font-size: 0.9em;\n-                margin-top: 5px;\n-            }\n-            .error {\n-              border: 3px solid red;\n-                font-size: 0.9em;\n-                margin-top: 5px;\n-            }\n-            @media (max-width: 500px) {\n-                input {\n-                    width: 90%;\n-                }\n-            }    \n-            \n-            `\n-        this.container.appendChild(this.styleElement)\n-        \n-        this.shadowRoot.appendChild(this.container)\n-    }\n-    connectedCallback(){\n+  setValid() {\n+    this.inputElement.classList.remove(\"error\");\n+    this.inputElement.classList.add(\"valid\");\n+  }\n \n-      this.updateAttributes() \n-       \n-    }\n-    setAttribute(name,value){\n-        this[name] = value \n-    }\n-    updateAttributes(){\n-      if(this.inputElement == null && this.field){\n-       this.inputElement = document.createElement(this.field.tag)\n-       if(this.field.tag == 'button'){\n-          if(this.field.value == \"submit\"){\n-          \n-          \n-          }\n-          this.action = this.field.value\n-       }\n-       this.inputElement.name = this.field.name \n-       this.name = this.inputElement.name\n-       const me = this\n-       this.inputElement.addEventListener(\"keyup\",(e)=>{\n-        if(e.key == 'Enter'){\n-            me.dispatchEvent(new Event(\"submit\"))\n-          }else if(me.field.value != e.target.value)\n-          {\n-            const event = new CustomEvent(\"change\", {detail:me,bubbles:true})\n-            me.dispatchEvent(event)\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.container = document.createElement('div');\n+    this.styleElement = document.createElement('style');\n+    this.styleElement.innerHTML = `\n+\n+      h1 {\n+        font-size: 2em;\n+        margin-bottom: 20px;\n+        margin-top: 0px;\n+      }\n+\n+      input {\n+          width: 90%;\n+          padding: 10px;\n+          margin: 10px 0;\n+          border-radius: 5px;\n+          font-size: 1em;\n+      }\n+\n+      button {\n+          width: 50%;\n+          padding: 10px;\n+          border: none;\n+          float: right;\n+          margin-top: 10px;\n+          margin-left: 10px;\n+          margin-right: 10px;\n+          border-radius: 5px;\n+          color: white;\n+          font-size: 1em;\n+          font-weight: bold;\n+          cursor: pointer;\n+          transition: background-color 0.3s;\n+          clear: both;\n+      }\n+\n+      button:hover {\n+      }\n+\n+      a {\n+          text-decoration: none;\n+          display: block;\n+          margin-top: 15px;\n+          font-size: 0.9em;\n+          transition: color 0.3s;\n+      }\n+\n+      a:hover {\n+      }\n+\n+      .valid {\n+          border: 1px solid green;\n+          color: green;\n+          font-size: 0.9em;\n+          margin-top: 5px;\n+      }\n+\n+      .error {\n+        border: 3px solid red;\n+          font-size: 0.9em;\n+          margin-top: 5px;\n+      }\n+\n+      @media (max-width: 500px) {\n+          input {\n+              width: 90%;\n           }\n-       })\n-       this.inputElement.addEventListener(\"click\",(e)=>{\n-          const event = new CustomEvent(\"click\",{detail:me,bubbles:true})\n-         me.dispatchEvent(event)    \n-       })\n-        this.container.appendChild(this.inputElement)\n+      }\n+    `;\n+    this.container.appendChild(this.styleElement);\n \n-}\n-      if(!this.field){\n-        return\n+    this.shadowRoot.appendChild(this.container);\n+  }\n+\n+  connectedCallback() {\n+    this.updateAttributes();\n+  }\n+\n+  setAttribute(name, value) {\n+    this[name] = value;\n+  }\n+\n+  updateAttributes() {\n+    if (this.inputElement == null && this.field) {\n+      this.inputElement = document.createElement(this.field.tag);\n+      if (this.field.tag === 'button' && this.field.value === \"submit\") {\n+        this.action = this.field.value;\n       }\n-        this.inputElement.setAttribute(\"type\",this.field.type == null ? 'input' : this.field.type)\n-        this.inputElement.setAttribute(\"name\",this.field.name  == null ? '' : this.field.name)\n-        \n-        if(this.field.text != null){\n-          this.inputElement.innerText = this.field.text \n-        }\n-        if(this.field.html != null){\n-          this.inputElement.innerHTML = this.field.html\n-        }\n-        if(this.field.class_name){\n-          this.inputElement.classList.add(this.field.class_name)\n-        }\n-        this.inputElement.setAttribute(\"tabindex\", this.field.index)\n-        this.inputElement.classList.add(this.field.name)\n-        this.value = this.field.value\n-        let place_holder = null \n-        if(this.field.place_holder)\n-            place_holder = this.field.place_holder\n-        if(this.field.required && place_holder){\n-          place_holder = place_holder\n-        }\n-        if(place_holder)\n-          this.field.place_holder = \"* \" + place_holder\n-        this.inputElement.setAttribute(\"placeholder\",place_holder)\n-        if(this.field.required)\n-            this.inputElement.setAttribute(\"required\",\"required\")\n-        else\n-            this.inputElement.removeAttribute(\"required\")\n-        if(!this.footerElement){\n-            this.footerElement = document.createElement('div')\n-            this.footerElement.style.clear = 'both'\n-            this.container.appendChild(this.footerElement)\n+      this.inputElement.name = this.field.name;\n+      this.name = this.inputElement.name;\n+\n+      const me = this;\n+      this.inputElement.addEventListener(\"keyup\", (e) => {\n+        if (e.key === 'Enter') {\n+          me.dispatchEvent(new Event(\"submit\"));\n+        } else if (me.field.value !== e.target.value) {\n+          const event = new CustomEvent(\"change\", { detail: me, bubbles: true });\n+          me.dispatchEvent(event);\n         }\n+      });\n+\n+      this.inputElement.addEventListener(\"click\", (e) => {\n+        const event = new CustomEvent(\"click\", { detail: me, bubbles: true });\n+        me.dispatchEvent(event);\n+      });\n+\n+      this.container.appendChild(this.inputElement);\n+    }\n+\n+    if (!this.field) {\n+      return;\n+    }\n+\n+    this.inputElement.setAttribute(\"type\", this.field.type ?? 'input');\n+    this.inputElement.setAttribute(\"name\", this.field.name ?? '');\n+\n+    if (this.field.text != null) {\n+      this.inputElement.innerText = this.field.text;\n+    }\n+    if (this.field.html != null) {\n+      this.inputElement.innerHTML = this.field.html;\n     }\n+    if (this.field.class_name) {\n+      this.inputElement.classList.add(this.field.class_name);\n+    }\n+    this.inputElement.setAttribute(\"tabindex\", this.field.index);\n+    this.inputElement.classList.add(this.field.name);\n+    this.value = this.field.value;\n+\n+    let place_holder = this.field.place_holder ?? null;\n+    if (this.field.required && place_holder) {\n+      place_holder = \"* \" + place_holder;\n+    }\n+    this.inputElement.setAttribute(\"placeholder\", place_holder);\n+    if (this.field.required) {\n+      this.inputElement.setAttribute(\"required\", \"required\");\n+    } else {\n+      this.inputElement.removeAttribute(\"required\");\n+    }\n+    if (!this.footerElement) {\n+      this.footerElement = document.createElement('div');\n+      this.footerElement.style.clear = 'both';\n+      this.container.appendChild(this.footerElement);\n+    }\n+  }\n }\n \n customElements.define('generic-field', GenericField);\n \n class GenericForm extends HTMLElement {\n-  fields = {}  \n-  form = {}\n-  constructor() {\n+  fields = {};\n+  form = {};\n \n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.styleElement = document.createElement(\"style\");\n+    this.styleElement.innerHTML = `\n \n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.styleElement = document.createElement(\"style\")\n-      this.styleElement.innerHTML = `\n-\n-      * {\n-          margin: 0;\n-          padding: 0;\n-          box-sizing: border-box;\n-          width:90%\n+    * {\n+        margin: 0;\n+        padding: 0;\n+        box-sizing: border-box;\n+        width: 90%;\n+    }\n \n+    div {\n+        border-radius: 10px;\n+        padding: 30px;\n+        width: 400px;\n+        box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        text-align: center;\n+    }\n+    @media (max-width: 500px) {\n+      width: 100%;\n+      height: 100%;\n+      form {\n+          height: 100%;\n+          width: 80%;\n       }\n+    }`;\n \n-      div {\n-          \n-          border-radius: 10px;\n-          padding: 30px;\n-          width: 400px;\n-          box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n-          text-align: center;\n+    this.container = document.createElement('div');\n+    this.container.appendChild(this.styleElement);\n+    this.container.classList.add(\"generic-form-container\");\n+    this.shadowRoot.appendChild(this.container);\n+  }\n \n+  connectedCallback() {\n+    const url = this.getAttribute('url');\n+    if (url) {\n+      const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n+      if (!url.startsWith(\"/\")) {\n+        fullUrl.searchParams.set('url', url);\n       }\n-      @media (max-width: 500px) {\n-        width:100%;\n-        height:100%;\n-          form {\n-              height:100%;\n-              width: 100%;\n-              width: 80%;\n-          }\n-      }`\n-      \n-      this.container = document.createElement('div');\n-      this.container.appendChild(this.styleElement)\n-      this.container.classList.add(\"generic-form-container\")\n-      this.shadowRoot.appendChild(this.container);\n+      this.loadForm(fullUrl.toString());\n+    } else {\n+      this.container.textContent = \"No URL provided!\";\n     }\n+  }\n \n-    connectedCallback() {\n-      const url = this.getAttribute('url');\n-      if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadForm(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No URL provided!\";\n+  async loadForm(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n       }\n-    }\n+      this.form = await response.json();\n \n-    async loadForm(url) {\n-      const me = this \n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n-        }\n-        me.form = await response.json();\n-        \n-        let fields = Object.values(me.form.fields)\n-        \n-        fields = fields.sort((a,b)=>{\n-          console.info(a.index,b.index)\n-          return a.index - b.index\n-        })  \n-        fields.forEach(field=>{\n-          const fieldElement = document.createElement('generic-field')\n-          me.fields[field.name] = fieldElement \n-          fieldElement.setAttribute(\"form\", me)\n-          fieldElement.setAttribute(\"field\", field)\n-          me.container.appendChild(fieldElement)\n-          fieldElement.updateAttributes() \n-          fieldElement.addEventListener(\"change\",(e)=>{\n-            me.form.fields[e.detail.name].value = e.detail.value\n-          })\n-          fieldElement.addEventListener(\"click\",async (e)=>{\n-            if(e.detail.type == \"button\"){\n-              if(e.detail.value == \"submit\")\n-              {\n-                const isValid = await me.validate()\n-                if(isValid){\n-                  const saveResult = await me.submit()\n-                  if(saveResult.redirect_url){\n-                    window.location.pathname = saveResult.redirect_url\n-                  }\n-                }\n+      let fields = Object.values(this.form.fields);\n+\n+      fields.sort((a, b) => a.index - b.index);\n+      fields.forEach(field => {\n+        const fieldElement = document.createElement('generic-field');\n+        this.fields[field.name] = fieldElement;\n+        fieldElement.setAttribute(\"form\", this);\n+        fieldElement.setAttribute(\"field\", field);\n+        this.container.appendChild(fieldElement);\n+        fieldElement.updateAttributes();\n+\n+        fieldElement.addEventListener(\"change\", (e) => {\n+          this.form.fields[e.detail.name].value = e.detail.value;\n+        });\n+\n+        fieldElement.addEventListener(\"click\", async (e) => {\n+          if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+            const isValid = await this.validate();\n+            if (isValid) {\n+              const saveResult = await this.submit();\n+              if (saveResult.redirect_url) {\n+                window.location.pathname = saveResult.redirect_url;\n               }\n             }\n-              \n-          })\n-        })\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n-      }\n-    }\n-    async validate(){\n-      const url = this.getAttribute(\"url\")\n-      const me = this\n-      let response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n+          }\n+        });\n       });\n \n-      \n-      const form = await response.json()\n-      Object.values(form.fields).forEach(field=>{\n-        if(!me.form.fields[field.name])\n-          return\n-          me.form.fields[field.name].is_valid = field.is_valid \n-        if(!field.is_valid){\n-          me.fields[field.name].setInvalid()\n-          me.fields[field.name].setErrors(field.errors)\n-        }else{\n-          me.fields[field.name].setValid()\n-        }\n-        me.fields[field.name].setAttribute(\"field\",field)\n-        me.fields[field.name].updateAttributes()\n-      })\n-      Object.values(form.fields).forEach(field=>{\n-        me.fields[field.name].setErrors(field.errors)\n-      })\n-      return form['is_valid']\n-    }\n-    async submit(){\n-      const me = this \n-      const url = me.getAttribute(\"url\")\n-      const response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify({\"action\":\"submit\", \"form\":me.form})\n-      });\n-      return await response.json()\n-    \n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n     }\n-    \n   }\n-  customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\n+\n+  async validate() {\n+    const url = this.getAttribute(\"url\");\n+\n+    let response = await fetch(url, {\n+      method: 'POST',\n+      headers: {\n+        'Content-Type': 'application/json'\n+      },\n+      body: JSON.stringify({ \"action\": \"validate\", \"form\": this.form })\n+    });\n+\n+    const form = await response.json();\n+    Object.values(form.fields).forEach(field => {\n+      if (!this.form.fields[field.name]) {\n+        return;\n+      }\n+      this.form.fields[field.name].is_valid = field.is_valid;\n+      if (!field.is_valid) {\n+        this.fields[field.name].setInvalid();\n+        this.fields[field.name].setErrors(field.errors);\n+      } else {\n+        this.fields[field.name].setValid();\n+      }\n+      this.fields[field.name].setAttribute(\"field\", field);\n+      this.fields[field.name].updateAttributes();\n+    });\n+    Object.values(form.fields).forEach(field => {\n+      this.fields[field.name].setErrors(field.errors);\n+    });\n+    return form['is_valid'];\n+  }\n+\n+  async submit() {\n+    const url = this.getAttribute(\"url\");\n+    const response = await fetch(url, {\n+      method: 'POST',\n+      headers: {\n+        'Content-Type': 'application/json'\n+      },\n+      body: JSON.stringify({ \"action\": \"submit\", \"form\": this.form })\n+    });\n+    return await response.json();\n+  }\n+}\n+\n+customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 61369e3..0328d78 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -1,47 +1,54 @@\n+\n+\n+\n+\n class HTMLFrame extends HTMLElement {\n     constructor() {\n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.container = document.createElement('div');\n-      this.shadowRoot.appendChild(this.container);\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.container = document.createElement('div');\n+        this.shadowRoot.appendChild(this.container);\n     }\n \n     connectedCallback() {\n-        this.container.classList.add(\"html_frame\")\n-      let url = this.getAttribute('url');\n-      if(!url.startsWith(\"https\")){\n-      }\n-      if (url) {\n-        let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        \n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadAndRender(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No source URL!\";\n-      }\n+        this.container.classList.add(\"html_frame\");\n+        let url = this.getAttribute('url');\n+        if (!url.startsWith(\"https\")) {\n+        }\n+        if (url) {\n+            const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n+            if (!url.startsWith(\"/\")) {\n+                fullUrl.searchParams.set('url', url);\n+            }\n+            this.loadAndRender(fullUrl.toString());\n+        } else {\n+            this.container.textContent = \"No source URL!\";\n+        }\n     }\n \n     async loadAndRender(url) {\n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Error: ${response.status} ${response.statusText}`);\n+        try {\n+            const response = await fetch(url);\n+            if (!response.ok) {\n+                throw new Error(`Error: ${response.status} ${response.statusText}`);\n+            }\n+            const html = await response.text();\n+            if (url.endsWith(\".md\")) {\n+                const markdownElement = document.createElement('div');\n+                markdownElement.innerHTML = html;\n+                this.outerHTML = html;\n+            } else {\n+                this.container.innerHTML = html;\n+            }\n+        } catch (error) {\n+            this.container.textContent = `Error: ${error.message}`;\n         }\n-        const html = await response.text();\n-        if(url.endsWith(\".md\")){\n-          const parent = this\n-          const markdownElement = document.createElement('div')\n-          markdownElement.innerHTML = html\n-          this.outerHTML = html\n-        }else{\n-          this.container.innerHTML = html;\n-        }\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n-      }\n     }\n-  }\n-  customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\n+}\n+\n+customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nindex e2b7a77..6450ebb 100644\n--- a/src/snek/static/markdown-frame.js\n+++ b/src/snek/static/markdown-frame.js\n@@ -1,39 +1,48 @@\n \n \n+\n+\n \n class HTMLFrame extends HTMLElement {\n-    constructor() {\n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.container = document.createElement('div');\n-      this.shadowRoot.appendChild(this.container);\n-    }\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.container = document.createElement('div');\n+    this.shadowRoot.appendChild(this.container);\n+  }\n \n-    connectedCallback() {\n-        this.container.classList.add(\"html_frame\")\n-      const url = this.getAttribute('url');\n-      if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadAndRender(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No source URL!\";\n-      }\n+  connectedCallback() {\n+    this.container.classList.add('html_frame');\n+    const url = this.getAttribute('url');\n+    if (url) {\n+      const fullUrl = url.startsWith('/')\n+        ? window.location.origin + url\n+        : new URL(window.location.origin + '/http-get');\n+      if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);\n+      this.loadAndRender(fullUrl.toString());\n+    } else {\n+      this.container.textContent = 'No source URL!';\n     }\n+  }\n \n-    async loadAndRender(url) {\n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Error: ${response.status} ${response.statusText}`);\n-        }\n-        const html = await response.text();\n-        this.container.innerHTML = html;\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n+  async loadAndRender(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Error: ${response.status} ${response.statusText}`);\n       }\n+      const html = await response.text();\n+      this.container.innerHTML = html;\n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n     }\n   }\n-  customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\n+}\n+\n+customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/media-upload.js b/src/snek/static/media-upload.js\nindex e190aed..e73d098 100644\n--- a/src/snek/static/media-upload.js\n+++ b/src/snek/static/media-upload.js\n@@ -1,20 +1,34 @@\n+\n+\n+\n+\n+\n+\n class TileGridElement extends HTMLElement {\n-                \n     constructor() {\n         super();\n-        this.attachShadow({mode: 'open'});\n+        this.attachShadow({ mode: 'open' });\n         this.gridId = this.getAttribute('grid');\n         this.component = document.createElement('div');\n-        this.shadowRoot.appendChild(this.component)\n+        this.shadowRoot.appendChild(this.component);\n     }\n-  \n \n     connectedCallback() {\n         console.log('connected');\n         this.styleElement = document.createElement('style');\n-        this.styleElement.innerText = `\n+        this.styleElement.textContent = `\n             .grid {\n-            padding: 10px;\n+                padding: 10px;\n                 display: flex;\n                 flex-wrap: wrap;\n                 gap: 10px;\n@@ -32,13 +46,13 @@ class TileGridElement extends HTMLElement {\n             .grid .tile:hover {\n                 transform: scale(1.1);\n             }\n-\n         `;\n         this.component.appendChild(this.styleElement);\n         this.container = document.createElement('div');\n         this.container.classList.add('gallery');\n         this.component.appendChild(this.container);\n     }\n+\n     addImage(src) {\n         const item = document.createElement('img');\n         item.src = src;\n@@ -47,38 +61,39 @@ class TileGridElement extends HTMLElement {\n         item.style.height = '100px';\n         this.container.appendChild(item);\n     }\n+\n     addImages(srcs) {\n         srcs.forEach(src => this.addImage(src));\n     }\n+\n     addElement(element) {\n-        element.cclassList.add('tile');\n+        element.classList.add('tile');\n         this.container.appendChild(element);\n     }\n-\n }\n \n class UploadButton extends HTMLElement {\n     constructor() {\n         super();\n-        this.attachShadow({mode: 'open'});\n+        this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div');\n-    \n-        this.shadowRoot.appendChild(this.component)\n-        window.u = this\n+        this.shadowRoot.appendChild(this.component);\n+        window.u = this;\n     }\n-    get gridSelector(){\n+\n+    get gridSelector() {\n         return this.getAttribute('grid');\n     }\n-    grid = null\n+    grid = null;\n \n     addImages(urls) {\n         this.grid.addImages(urls);\n     }\n-    connectedCallback()\n-    {\n+\n+    connectedCallback() {\n         console.log('connected');\n         this.styleElement = document.createElement('style');\n-        this.styleElement.innerHTML = `\n+        this.styleElement.textContent = `\n         .upload-button {\n             display: flex;\n             flex-direction: column;\n@@ -112,7 +127,6 @@ class UploadButton extends HTMLElement {\n             const files = e.target.files;\n             const urls = [];\n             for (let i = 0; i < files.length; i++) {\n-                const file = files[i];\n                 const reader = new FileReader();\n                 reader.onload = (e) => {\n                     urls.push(e.target.result);\n@@ -120,7 +134,7 @@ class UploadButton extends HTMLElement {\n                         this.addImages(urls);\n                     }\n                 };\n-                reader.readAsDataURL(file);\n+                reader.readAsDataURL(files[i]);\n             }\n          });\n         const label = document.createElement('label');\n@@ -130,38 +144,32 @@ class UploadButton extends HTMLElement {\n     }\n }\n \n-customElements.define('upload-button', UploadButton);  \n-\n+customElements.define('upload-button', UploadButton);\n customElements.define('tile-grid', TileGridElement);\n \n class MeniaUploadElement extends HTMLElement {\n-\n     constructor(){\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.component = document.createElement(\"div\")\n-        alert('aaaa')\n-        this.shadowRoot.appendChild(this.component)\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement(\"div\");\n+        alert('aaaa');\n+        this.shadowRoot.appendChild(this.component);\n     }\n+\n     connectedCallback() {\n-        \n-        this.container = document.createElement(\"div\")\n-        this.component.style.height = '100%'\n-        this.component.style.backgroundColor ='blue';\n-        this.shadowRoot.appendChild(this.container)\n-\n-        this.tileElement = document.createElement(\"tile-grid\")\n-        this.tileElement.style.backgroundColor = 'red'\n-        this.tileElement.style.height = '100%'\n-        this.component.appendChild(this.tileElement)\n-        \n-        this.uploadButton = document.createElement('upload-button')\n-        this.component.appendChild(this.uploadButton)\n-        \n-    }\n+        this.container = document.createElement(\"div\");\n+        this.component.style.height = '100%';\n+        this.component.style.backgroundColor = 'blue';\n+        this.shadowRoot.appendChild(this.container);\n \n+        this.tileElement = document.createElement(\"tile-grid\");\n+        this.tileElement.style.backgroundColor = 'red';\n+        this.tileElement.style.height = '100%';\n+        this.component.appendChild(this.tileElement);\n+\n+        this.uploadButton = document.createElement('upload-button');\n+        this.component.appendChild(this.uploadButton);\n+    }\n }\n \n-customElements.define('menia-upload', MeniaUploadElement)\n\\ No newline at end of file\n+customElements.define('menia-upload', MeniaUploadElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nindex 69a3f87..a7a8a78 100644\n--- a/src/snek/static/message-list-manager.js\n+++ b/src/snek/static/message-list-manager.js\n@@ -1,23 +1,44 @@\n+\n+\n+\n \n \n class MessageListManagerElement extends HTMLElement {\n     constructor() {\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.container = document.createElement(\"div\")\n-        this.shadowRoot.appendChild(this.container)\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.container = document.createElement(\"div\");\n+        this.shadowRoot.appendChild(this.container);\n     }\n \n     async connectedCallback() {\n-        let channels = await app.rpc.getChannels()\n-        const me = this \n-        channels.forEach(channel=>{\n-            const messageList = document.createElement(\"message-list\")\n-            messageList.setAttribute(\"channel\",channel.uid)\n-            me.container.appendChild(messageList)\n-        })\n+        const channels = await app.rpc.getChannels();\n+        channels.forEach(channel => {\n+            const messageList = document.createElement(\"message-list\");\n+            messageList.setAttribute(\"channel\", channel.uid);\n+            this.container.appendChild(messageList);\n+        });\n     }\n-\n }\n \n-customElements.define(\"message-list-manager\",MessageListManagerElement)\n\\ No newline at end of file\n+customElements.define(\"message-list-manager\", MessageListManagerElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 1272281..17f3066 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -1,115 +1,113 @@\n \n \n-class MessageListElement extends HTMLElement {\n+\n \n+class MessageListElement extends HTMLElement {\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n-    messages = []\n-    room = null\n-    url = null\n-    container = null\n-    messageEventSchedule = null\n-    observer = null\n+\n+    messages = [];\n+    room = null;\n+    url = null;\n+    container = null;\n+    messageEventSchedule = null;\n+    observer = null;\n+\n     constructor() {\n-        super()\n+        super();\n         this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div')\n-        this.shadowRoot.appendChild(this.component)\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n     }\n+\n     linkifyText(text) {\n         const urlRegex = /https?:\\/\\/[^\\s]+/g;\n-\n-        return text.replace(urlRegex, (url) => {\n-            return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n-        });\n-\n+        return text.replace(urlRegex, (url) => `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`);\n     }\n-    timeAgo(date1, date2) {\n-        const diffMs = Math.abs(date2 - date1); \n \n+    timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1);\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n         const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n         const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+\n         if (days) {\n-            if (days > 1)\n-                return `${days} days ago`\n-            else\n-                return `${days} day ago`\n+            return `${days} ${days > 1 ? 'days' : 'day'} ago`;\n         }\n         if (hours) {\n-            if (hours > 1)\n-                return `${hours} hours ago`\n-            else\n-                return `${hours} hour ago`\n+            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;\n         }\n-        if (minutes)\n-            if (minutes > 1)\n-                return `${minutes} minutes ago`\n-            else\n-                return `${minutes} minute ago`\n-\n-        return `just now`\n+        if (minutes) {\n+            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;\n+        }\n+        return 'just now';\n     }\n+\n     timeDescription(isoDate) {\n-        const date = new Date(isoDate)\n+        const date = new Date(isoDate);\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n-        let timeStr = `${hours}:${minutes}`\n-        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now())\n-        return timeStr\n+        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n+        return timeStr;\n     }\n+\n     createElement(message) {\n-        const element = document.createElement(\"div\")\n-        element.dataset.uid = message.uid\n-        element.dataset.color = message.color\n-        element.dataset.channel_uid = message.channel_uid\n-        element.dataset.user_nick = message.user_nick\n-        element.dataset.created_at = message.created_at\n-        element.dataset.user_uid = message.user_uid\n-        element.dataset.message = message.message\n-\n-        element.classList.add(\"message\")\n-        if (!this.messages.length) {\n-            element.classList.add(\"switch-user\")\n-        } else if (this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n-            element.classList.add(\"switch-user\")\n+        const element = document.createElement(\"div\");\n+        element.dataset.uid = message.uid;\n+        element.dataset.color = message.color;\n+        element.dataset.channel_uid = message.channel_uid;\n+        element.dataset.user_nick = message.user_nick;\n+        element.dataset.created_at = message.created_at;\n+        element.dataset.user_uid = message.user_uid;\n+        element.dataset.message = message.message;\n+\n+        element.classList.add(\"message\");\n+        if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n+            element.classList.add(\"switch-user\");\n         }\n-        const avatar = document.createElement(\"div\")\n-        avatar.classList.add(\"avatar\")\n-        avatar.style.backgroundColor = message.color\n-        avatar.style.color = \"black\"\n-        avatar.innerText = message.user_nick[0]\n-        const messageContent = document.createElement(\"div\")\n-        messageContent.classList.add(\"message-content\")\n-        const author = document.createElement(\"div\")\n-        author.classList.add(\"author\")\n-        author.style.color = message.color\n-        author.textContent = message.user_nick\n-        const text = document.createElement(\"div\")\n-        text.classList.add(\"text\")\n-        if (message.html)\n-            text.innerHTML = message.html\n-        const time = document.createElement(\"div\")\n-        time.classList.add(\"time\")\n-        time.dataset.created_at = message.created_at\n-        messageContent.appendChild(author)\n-        time.textContent = this.timeDescription(message.created_at)\n-        messageContent.appendChild(text)\n-        messageContent.appendChild(time)\n-        element.appendChild(avatar)\n-        element.appendChild(messageContent)\n-\n-\n-\n-\n-        message.element = element\n-\n-        return element\n+\n+        const avatar = document.createElement(\"div\");\n+        avatar.classList.add(\"avatar\");\n+        avatar.style.backgroundColor = message.color;\n+        avatar.style.color = \"black\";\n+        avatar.innerText = message.user_nick[0];\n+\n+        const messageContent = document.createElement(\"div\");\n+        messageContent.classList.add(\"message-content\");\n+\n+        const author = document.createElement(\"div\");\n+        author.classList.add(\"author\");\n+        author.style.color = message.color;\n+        author.textContent = message.user_nick;\n+\n+        const text = document.createElement(\"div\");\n+        text.classList.add(\"text\");\n+        if (message.html) text.innerHTML = message.html;\n+\n+        const time = document.createElement(\"div\");\n+        time.classList.add(\"time\");\n+        time.dataset.created_at = message.created_at;\n+        time.textContent = this.timeDescription(message.created_at);\n+\n+        messageContent.appendChild(author);\n+        messageContent.appendChild(text);\n+        messageContent.appendChild(time);\n+\n+        element.appendChild(avatar);\n+        element.appendChild(messageContent);\n+\n+        message.element = element;\n+\n+        return element;\n     }\n-    addMessage(message) {\n \n+    addMessage(message) {\n         const obj = new models.Message(\n             message.uid,\n             message.channel_uid,\n@@ -120,52 +118,52 @@ class MessageListElement extends HTMLElement {\n             message.html,\n             message.created_at,\n             message.updated_at\n-        )\n-        const element = this.createElement(obj)\n-        this.messages.push(obj)\n-        this.container.appendChild(element)\n-        const me = this\n+        );\n \n-        this.messageEventSchedule.delay(() => {\n-            me.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }))\n-\n-        })\n+        const element = this.createElement(obj);\n+        this.messages.push(obj);\n+        this.container.appendChild(element);\n \n+        this.messageEventSchedule.delay(() => {\n+            this.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }));\n+        });\n \n-        return obj\n+        return obj;\n     }\n+\n     scrollBottom() {\n         this.container.scrollTop = this.container.scrollHeight;\n     }\n+\n     connectedCallback() {\n-        const link = document.createElement('link')\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.component.classList.add(\"chat-messages\")\n-        this.container = document.createElement('div')\n-        this.component.appendChild(this.container)\n-        this.messageEventSchedule = new Schedule(500)\n-        this.messages = []\n-        this.channel_uid = this.getAttribute(\"channel\")\n-        const me = this\n+        const link = document.createElement('link');\n+        link.rel = 'stylesheet';\n+        link.href = '/base.css';\n+        this.component.appendChild(link);\n+        this.component.classList.add(\"chat-messages\");\n+\n+        this.container = document.createElement('div');\n+        this.component.appendChild(this.container);\n+\n+        this.messageEventSchedule = new Schedule(500);\n+        this.messages = [];\n+        this.channel_uid = this.getAttribute(\"channel\");\n+\n         app.addEventListener(this.channel_uid, (data) => {\n-            me.addMessage(data)\n-        })\n-        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }))\n+            this.addMessage(data);\n+        });\n \n-        this.timeUpdateInterval = setInterval(() => {\n-            me.messages.forEach((message) => {\n-                const newText = me.timeDescription(message.created_at)\n+        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }));\n \n+        this.timeUpdateInterval = setInterval(() => {\n+            this.messages.forEach((message) => {\n+                const newText = this.timeDescription(message.created_at);\n                 if (newText != message.element.innerText) {\n-                    message.element.querySelector(\".time\").innerText = newText\n+                    message.element.querySelector(\".time\").innerText = newText;\n                 }\n-            })\n-        }, 30000)\n-\n+            });\n+        }, 30000);\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n+customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex 1c05b42..67daa57 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -1,26 +1,26 @@\n+\n+\n+\n+\n class MessageModel {\n-    message = null \n-    html = null\n-    user_uid = null \n-    channel_uid = null \n-    created_at = null \n-    updated_at = null  \n-    element = null \n-    color = null\n-    constructor(uid, channel_uid,user_uid,user_nick, color,message,html,created_at, updated_at){\n-        this.uid = uid \n-        this.message = message \n-        this.html = html \n-        this.user_uid = user_uid \n+    constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {\n+        this.uid = uid\n+        this.message = message\n+        this.html = html\n+        this.user_uid = user_uid\n         this.user_nick = user_nick\n         this.color = color\n-        this.channel_uid = channel_uid  \n+        this.channel_uid = channel_uid\n         this.created_at = created_at\n         this.updated_at = updated_at\n+        this.element = null\n     }       \n }\n \n const models = {\n     Message: MessageModel\n-\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 3ed8069..36ae803 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -1,47 +1,54 @@\n \n+\n+\n \n class Schedule {\n+  constructor(msDelay = 100) {\n+    this.msDelay = msDelay;\n+    this._once = false;\n+    this.timeOutCount = 0;\n+    this.timeOut = null;\n+    this.interval = null;\n+  }\n \n-    constructor(msDelay) {\n-        if(!msDelay){\n-            msDelay = 100\n-        }\n-        this.msDelay = msDelay\n-        this._once = false\n-        this.timeOutCount = 0;\n-        this.timeOut = null \n-        this.interval = null \n-    }\n-    cancelRepeat() {\n-        clearInterval(this.interval)\n-        this.interval = null \n-    }\n-    cancelDelay() {\n-        clearTimeout(this.timeOut)\n-        this.timeOut = null\n-    }\n-    repeat(func){\n-        if(this.interval){\n-            return false \n-        }\n-        this.interval = setInterval(()=>{\n-            func()\n-        }, this.msDelay)\n-    }\n-    delay(func) {\n-        this.timeOutCount++\n-        if(this.timeOut){\n-            this.cancelDelay()\n-        }\n-        const me = this \n-        this.timeOut = setTimeout(()=>{\n-            func(me.timeOutCount)\n-            clearTimeout(me.timeOut)\n-            me.timeOut = null\n-            \n-            me.cancelDelay()\n-            me.timeOutCount = 0\n-        }, this.msDelay)\n+  cancelRepeat() {\n+    clearInterval(this.interval);\n+    this.interval = null;\n+  }\n+\n+  cancelDelay() {\n+    clearTimeout(this.timeOut);\n+    this.timeOut = null;\n+  }\n+\n+  repeat(func) {\n+    if (this.interval) {\n+      return false;\n     }\n+    this.interval = setInterval(() => {\n+      func();\n+    }, this.msDelay);\n+  }\n \n+  delay(func) {\n+    this.timeOutCount++;\n+    if (this.timeOut) {\n+      this.cancelDelay();\n+    }\n+    const me = this;\n+    this.timeOut = setTimeout(() => {\n+      func(me.timeOutCount);\n+      clearTimeout(me.timeOut);\n+      me.timeOut = null;\n+      me.cancelDelay();\n+      me.timeOutCount = 0;\n+    }, this.msDelay);\n+  }\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nnew file mode 100644\nindex 0000000..c6cfaa2\n--- /dev/null\n+++ b/src/snek/static/upload-button.js\n@@ -0,0 +1,121 @@\n+\n+\n+\n+\n+class UploadButtonElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+    }\n+\n+    async uploadFiles() {\n+        const fileInput = this.container.querySelector('.file-input');\n+        const uploadButton = this.container.querySelector('.upload-button');\n+\n+        if (!fileInput.files.length) {\n+            return;\n+        }\n+\n+        const files = fileInput.files;\n+        const formData = new FormData();\n+        for (let i = 0; i < files.length; i++) {\n+            formData.append('files[]', files[i]);\n+        }\n+\n+        const request = new XMLHttpRequest();\n+        request.open('POST', '/upload', true);\n+\n+        request.upload.onprogress = function (event) {\n+            if (event.lengthComputable) {\n+                const percentComplete = (event.loaded / event.total) * 100;\n+                uploadButton.innerText = `${Math.round(percentComplete)}%`;\n+            }\n+        };\n+\n+        request.onload = function () {\n+            if (request.status === 200) {\n+                progressBar.style.width = '0%';\n+                uploadButton.innerHTML = '\ud83d\udce4';\n+            } else {\n+                alert('Upload failed');\n+            }\n+        };\n+\n+        request.onerror = function () {\n+            alert('Error while uploading.');\n+        };\n+\n+        request.send(formData);\n+    }\n+\n+    connectedCallback() {\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerHTML = `\n+            body {\n+                font-family: Arial, sans-serif;\n+                display: flex;\n+                justify-content: center;\n+                align-items: center;\n+                height: 100vh;\n+            }\n+            .upload-container {\n+                position: relative;\n+            }\n+            .upload-button {\n+                display: flex;\n+                align-items: center;\n+                justify-content: center;\n+                padding: 10px 20px;\n+                color: white;\n+                border: none;\n+                border-radius: 5px;\n+                cursor: pointer;\n+                font-size: 16px;\n+                position: relative;\n+                overflow: hidden;\n+            }\n+            .upload-button i {\n+                margin-right: 8px;\n+            }\n+            .progress {\n+                position: absolute;\n+                left: 0;\n+                top: 0;\n+                height: 100%;\n+                background: rgba(255, 255, 255, 0.4);\n+                width: 0%;\n+            }\n+            .hidden-input {\n+                display: none;\n+            }\n+        `;\n+        this.shadowRoot.appendChild(this.styleElement);\n+\n+        this.container = document.createElement('div');\n+        this.container.innerHTML = `\n+            <div class=\"upload-container\">\n+                <button class=\"upload-button\">\n+                   \ud83d\udce4\n+                </button>\n+                <input class=\"hidden-input file-input\" type=\"file\" multiple />\n+            </div>\n+        `;\n+        this.shadowRoot.appendChild(this.container);\n+\n+        this.uploadButton = this.container.querySelector('.upload-button');\n+        this.fileInput = this.container.querySelector('.hidden-input');\n+        this.uploadButton.addEventListener('click', () => {\n+            this.fileInput.click();\n+        });\n+        this.fileInput.addEventListener('change', () => {\n+            this.uploadFiles();\n+        });\n+    }\n+}\n+\n+customElements.define('upload-button', UploadButtonElement);\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 532f75f..0aaffc9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,7 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n-  <script src=\"/media-upload.js\"></script>\n+  <script src=\"/upload-button.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>"}
{"repo": ".", "date": "2025-02-03", "line": "fix: Remove unnecessary white-space and improve text wrapping", "commit": "f395d1617394045cac7c41af0cd5ce9d6ef55ed8", "diff": "commit f395d1617394045cac7c41af0cd5ce9d6ef55ed8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 20:46:41 2025 +0100\n\n    Useless button + wrapping\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 2393a62..5db5aa9 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -180,7 +180,6 @@ message-list {\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n-   white-space: pre-wrap;\n   word-break: break-word;\n   overflow-wrap: break-word;\n \n@@ -240,7 +239,6 @@ message-list {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n-  white-space: pre-wrap; \n   hyphens: auto;\n     img {\n       max-width: 90%;"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed setup.cfg and updated dependencies and code\n\nThis commit removes the `setup.cfg` file and adjusts the Dockerfiles and pyproject.toml accordingly. It also includes several code refactors to improve socket connection handling and benchmark functionality.\n", "commit": "084f8dba2075aec93d9d88fd7cdd7f67fc63a212", "diff": "commit 084f8dba2075aec93d9d88fd7cdd7f67fc63a212\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 21:15:18 2025 +0100\n\n    Heavy repair.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex 9af8e87..ffdc3d7 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -30,7 +30,6 @@ RUN apk add --no-cache \\\n   && apk del .build-deps\n COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n-COPY setup.cfg setup.cfg \n COPY pyproject.toml pyproject.toml \n COPY src src\n RUN pip install --upgrade pip\ndiff --git a/DockerfileDrive b/DockerfileDrive\nindex 28f183a..0a03850 100644\n--- a/DockerfileDrive\n+++ b/DockerfileDrive\n@@ -4,7 +4,6 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n \n \n-COPY setup.cfg setup.cfg \n COPY pyproject.toml pyproject.toml \n COPY src src\n COpy ssh_host_key ssh_host_key\ndiff --git a/pyproject.toml b/pyproject.toml\nindex ed36d70..5a147ae 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -27,6 +27,7 @@ dependencies = [\n     \"requests\",\n     \"asyncssh\",\n     \"emoji\",\n-    \"pywebpush\"\n+    \"pywebpush\",\n+    \"aiofiles\"\n ]\n \ndiff --git a/setup.cfg b/setup.cfg\ndeleted file mode 100644\nindex 045fc92..0000000\n--- a/setup.cfg\n+++ /dev/null\n@@ -1,29 +0,0 @@\n-[metadata]\n-name = snek\n-version = 1.0.0\n-description = Snek chat server \n-author = retoor\n-author_email = retoor@molodetz.nl\n-license = MIT\n-long_description = file: README.md\n-long_description_content_type = text/markdown\n-\n-[options]\n-packages = find:\n-package_dir =\n-    = src\n-python_requires = >=3.7\n-install_requires =\n-    beautifulsoup4\n-    gunicorn\n-    imgkit\n-    wkhtmltopdf\n-    shed\n-\n-[options.packages.find]\n-where = src\n-\n-[options.entry_points]\n-console_scripts =\n-    snek.serve = snek.server:cli\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex ada9654..06fef95 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -166,9 +166,10 @@ class Socket extends EventHandler {\n         if (this.ensureTimer) {\n             return this.connect();\n         }\n+        const me = this;\n         this.ensureTimer = setInterval(() => {\n-            if (this.isConnecting) this.isConnecting = false;\n-            this.connect();\n+            if (me.isConnecting) me.isConnecting = false;\n+            me.connect();\n         }, 5000);\n         return this.connect();\n     }\n@@ -178,32 +179,34 @@ class Socket extends EventHandler {\n     }\n \n     connect() {\n+        \n+        const me = this \n         if (this.isConnected || this.isConnecting) {\n             return new Promise((resolve) => {\n-                this.connectPromises.push(resolve);\n-                if (!this.isConnected) resolve(this);\n+                me.connectPromises.push(resolve);\n+                if (!me.isConnecting) resolve(me);\n             });\n         }\n         this.isConnecting = true;\n         return new Promise((resolve) => {\n-            this.connectPromises.push(resolve);\n+            me.connectPromises.push(resolve);\n             console.debug(\"Connecting..\");\n \n-            const ws = new WebSocket(this.url);\n+            const ws = new WebSocket(me.url);\n             ws.onopen = () => {\n-                this.ws = ws;\n-                this.isConnected = true;\n-                this.isConnecting = false;\n+                me.ws = ws;\n+                me.isConnected = true;\n+                me.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    this.onData(JSON.parse(event.data));\n+                    me.onData(JSON.parse(event.data));\n                 };\n                 ws.onclose = () => {\n-                    this.onClose();\n+                    me.onClose();\n                 };\n                 ws.onerror = () => {\n-                    this.onClose();\n+                    me.onClose();\n                 };\n-                this.connectPromises.forEach(resolver => resolver(this));\n+                me.connectPromises.forEach(resolver => resolver(me));\n             };\n         });\n     }\n@@ -233,9 +236,10 @@ class Socket extends EventHandler {\n             method,\n             args,\n         };\n+        const me = this \n         return new Promise((resolve) => {\n-            this.addEventListener(call.callId, data => resolve(data));\n-            this.sendJson(call);\n+            me.addEventListener(call.callId, data => resolve(data));\n+            me.sendJson(call);\n         });\n     }\n \n@@ -281,12 +285,13 @@ class App extends EventHandler {\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n+        const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            this.emit(data.channel_uid, data);\n+            me.emit(data.channel_uid, data);\n         });\n \n         this.rpc.getUser(null).then(user => {\n-            this.user = user;\n+            me.user = user;\n         });\n     }\n \n@@ -296,14 +301,15 @@ class App extends EventHandler {\n \n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n+        const me = this; \n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n-                    this.rpc.sendMessage(channel.uid, `${message} ${i}`);\n+                    me.rpc.sendMessage(channel.uid, `${message} ${i}`);\n                 });\n             }));\n         }\n     }\n }\n \n-const app = new App();\n\\ No newline at end of file\n+const app = new App();"}
{"repo": ".", "date": "2025-02-04", "line": "feat: Added drive service and upload functionality", "commit": "6f9adfe67fd551dd99746c40bb55706a7ffcef3b", "diff": "commit 6f9adfe67fd551dd99746c40bb55706a7ffcef3b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 4 23:38:13 2025 +0100\n\n    Drive service.\n\ndiff --git a/.gitignore b/.gitignore\nindex c1f3aef..ce8bd16 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -8,6 +8,8 @@ snek.d*\n *.zip\n *.db*\n cache\n+drive\n+\n __pycache__/\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 137abf0..8cfed8f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -26,8 +26,8 @@ from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n+from snek.view.upload import UploadView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -81,6 +81,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive.bin\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 1841346..e4c67b0 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -5,6 +5,8 @@ from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n+from snek.mapper.drive import DriveMapper \n+from snek.mapper.drive_item import DriveItemMapper\n from snek.system.object import Object\n \n \n@@ -17,6 +19,8 @@ def get_mappers(app=None):\n             \"channel\": ChannelMapper(app=app),\n             \"channel_message\": ChannelMessageMapper(app=app),\n             \"notification\": NotificationMapper(app=app),\n+            \"drive_item\": DriveItemMapper(app=app),\n+            \"drive\": DriveMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nnew file mode 100644\nindex 0000000..970788a\n--- /dev/null\n+++ b/src/snek/mapper/drive.py\n@@ -0,0 +1,7 @@\n+from snek.model.drive import DriveModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class DriveMapper(BaseMapper):\n+    table_name = 'drive'\n+    model_class = DriveModel \ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nnew file mode 100644\nindex 0000000..c35afe1\n--- /dev/null\n+++ b/src/snek/mapper/drive_item.py\n@@ -0,0 +1,7 @@\n+from snek.system.mapper import BaseMapper\n+from snek.model.drive_item import DriveItemModel \n+\n+class DriveItemMapper(BaseMapper):\n+    \n+    model_class = DriveItemModel\n+    table_name = 'drive_item'\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nnew file mode 100644\nindex 0000000..a310bbd\n--- /dev/null\n+++ b/src/snek/model/drive.py\n@@ -0,0 +1,7 @@\n+from snek.system.model import BaseModel,ModelField\n+\n+\n+class DriveModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    \ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nnew file mode 100644\nindex 0000000..74b8deb\n--- /dev/null\n+++ b/src/snek/model/drive_item.py\n@@ -0,0 +1,9 @@\n+from snek.system.model import BaseModel,ModelField \n+\n+\n+class DriveItemModel(BaseModel):\n+    drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n+    name = ModelField(name=\"name\", required=True,kind=str)\n+    path = ModelField(name=\"path\", required=True,kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True,kind=str) \n+    file_size = ModelField(name=\"file_size\", required=True,kind=int)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 97fbaae..e521c7b 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -8,6 +8,8 @@ from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n+from snek.service.drive import DriveService\n+from snek.service.drive_item import DriveItemService\n from snek.system.object import Object\n \n \n@@ -23,6 +25,8 @@ def get_services(app):\n             \"socket\": SocketService(app=app),\n             \"notification\": NotificationService(app=app),\n             \"util\": UtilService(app=app),\n+            \"drive\": DriveService(app=app),\n+            \"drive_item\": DriveItemService(app=app)\n         }\n     )\n \ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 8765d53..dcc12d5 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -29,10 +29,7 @@ class ChannelMessageService(BaseService):\n             model[\"html\"] = template.render(**context)\n         except Exception as ex:\n             print(ex,flush=True)\n-        print(\"RENDER\",flush=True)\n-        print(\"RECORD\",context,flush=True)\n         \n-        print(\"AFTER RENDER\",flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nnew file mode 100644\nindex 0000000..9d409a8\n--- /dev/null\n+++ b/src/snek/service/drive.py\n@@ -0,0 +1,21 @@\n+from snek.system.service import BaseService\n+\n+\n+class DriveService(BaseService):\n+    \n+    mapper_name = \"drive\"\n+\n+    async def get_by_user(self, user_uid):\n+        drives = [] \n+        async for model in self.find(user_uid=user_uid):\n+            drives.append(model)\n+        return drives \n+\n+    async def get_or_create(self, user_uid):\n+        drives = await self.get_by_user(user_uid=user_uid)\n+        if len(drives) == 0:\n+            model = await self.new()\n+            model['user_uid'] = user_uid  \n+            await self.save(model)\n+            return model \n+        return drives[0]\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nnew file mode 100644\nindex 0000000..058f55e\n--- /dev/null\n+++ b/src/snek/service/drive_item.py\n@@ -0,0 +1,18 @@\n+from snek.system.service import BaseService \n+\n+\n+class DriveItemService(BaseService):\n+\n+    mapper_name = \"drive_item\"\n+\n+    async def create(self, drive_uid, name, path, type_,size):\n+        model = await self.new() \n+        model['drive_uid'] = drive_uid \n+        model['name'] = name \n+        model['path'] = str(path) \n+        model['file_type'] = type_ \n+        model['file_size'] = size\n+        if await self.save(model):\n+            return model \n+        errors = await model.errors\n+        raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 06fef95..a35f153 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -189,24 +189,24 @@ class Socket extends EventHandler {\n         }\n         this.isConnecting = true;\n         return new Promise((resolve) => {\n-            me.connectPromises.push(resolve);\n+            this.connectPromises.push(resolve);\n             console.debug(\"Connecting..\");\n \n-            const ws = new WebSocket(me.url);\n+            const ws = new WebSocket(this.url);\n             ws.onopen = () => {\n-                me.ws = ws;\n-                me.isConnected = true;\n-                me.isConnecting = false;\n+                this.ws = ws;\n+                this.isConnected = true;\n+                this.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    me.onData(JSON.parse(event.data));\n+                    this.onData(JSON.parse(event.data));\n                 };\n                 ws.onclose = () => {\n-                    me.onClose();\n+                    this.onClose();\n                 };\n                 ws.onerror = () => {\n-                    me.onClose();\n+                    this.onClose();\n                 };\n-                me.connectPromises.forEach(resolver => resolver(me));\n+                this.connectPromises.forEach(resolver => resolver(this));\n             };\n         });\n     }\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 6a9353c..c1d767d 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -7,14 +7,23 @@\n \n class ChatInputElement extends HTMLElement {\n-  \n+  _chatWindow = null \n   constructor() {\n     super();\n     this.attachShadow({ mode: 'open' });\n     this.component = document.createElement('div');\n     this.shadowRoot.appendChild(this.component);\n   }\n+  set chatWindow(value){\n+    this._chatWindow = value \n \n+  }\n+  get chatWindow(){\n+    return this._chatWindow \n+  }\n+  get channelUid() {\n+    return this.chatWindow.channel.uid\n+  }\n   connectedCallback() {\n     const link = document.createElement('link');\n     link.rel = 'stylesheet';\n@@ -28,7 +37,8 @@ class ChatInputElement extends HTMLElement {\n       <upload-button></upload-button>\n     `;\n     this.textBox = this.container.querySelector('textarea');\n-\n+    this.uploadButton = this.container.querySelector('upload-button');\n+    this.uploadButton.chatInput = this \n     this.textBox.addEventListener('input', (e) => {\n       this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n       const message = e.target.value;\n@@ -56,4 +66,4 @@ class ChatInputElement extends HTMLElement {\n   }\n }\n \n-customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\n+customElements.define('chat-input', ChatInputElement);\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 2c2973a..0b341dc 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -13,7 +13,7 @@\n \n class ChatWindowElement extends HTMLElement {\n     receivedHistory = false;\n-\n+    channel = null\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n@@ -47,6 +47,7 @@ class ChatWindowElement extends HTMLElement {\n \n         const channels = await app.rpc.getChannels();\n         const channel = channels[0];\n+        this.channel = channel;\n         chatTitle.innerText = channel.name;\n \n         const channelElement = document.createElement('message-list');\n@@ -54,6 +55,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(channelElement);\n \n         const chatInput = document.createElement('chat-input');\n+        chatInput.chatWindow = this;\n         chatInput.addEventListener(\"submit\", (e) => {\n             app.rpc.sendMessage(channel.uid, e.detail);\n         });\n@@ -75,4 +77,4 @@ class ChatWindowElement extends HTMLElement {\n     }\n }\n \n-customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\n+customElements.define('chat-window', ChatWindowElement);\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex c6cfaa2..6e3052f 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -10,7 +10,7 @@ class UploadButtonElement extends HTMLElement {\n         super();\n         this.attachShadow({ mode: 'open' });\n     }\n-\n+    chatInput = null \n     async uploadFiles() {\n         const fileInput = this.container.querySelector('.file-input');\n         const uploadButton = this.container.querySelector('.upload-button');\n@@ -21,12 +21,13 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n+        formData.append('channel_uid', this.chatInput.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n \n         const request = new XMLHttpRequest();\n-        request.open('POST', '/upload', true);\n+        request.open('POST', '/drive.bin', true);\n \n         request.upload.onprogress = function (event) {\n             if (event.lengthComputable) {\n@@ -37,7 +38,6 @@ class UploadButtonElement extends HTMLElement {\n \n         request.onload = function () {\n             if (request.status === 200) {\n-                progressBar.style.width = '0%';\n                 uploadButton.innerHTML = '\ud83d\udce4';\n             } else {\n                 alert('Upload failed');\n@@ -50,7 +50,7 @@ class UploadButtonElement extends HTMLElement {\n \n         request.send(formData);\n     }\n-\n+    channelUid = null\n     connectedCallback() {\n         this.styleElement = document.createElement('style');\n         this.styleElement.innerHTML = `\n@@ -95,7 +95,6 @@ class UploadButtonElement extends HTMLElement {\n             }\n         `;\n         this.shadowRoot.appendChild(this.styleElement);\n-\n         this.container = document.createElement('div');\n         this.container.innerHTML = `\n             <div class=\"upload-container\">\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nnew file mode 100644\nindex 0000000..ff208e9\n--- /dev/null\n+++ b/src/snek/view/upload.py\n@@ -0,0 +1,56 @@\n+from snek.system.view import BaseView\n+import aiofiles \n+import pathlib\n+from aiohttp import web\n+import uuid \n+\n+UPLOAD_DIR = pathlib.Path(\"./drive\")\n+\n+class UploadView(BaseView):\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        drive_item = await self.services.drive_item.get(uid)\n+        \n+        print(await drive_item.to_json(),flush=True)\n+        return web.FileResponse(drive_item[\"path\"])       \n+\n+    async def post(self):\n+        reader = await self.request.multipart()\n+        files = []  \n+\n+        UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n+\n+        channel_uid = None \n+\n+        drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n+\n+        print(str(drive),flush=True)\n+\n+        while field := await reader.next():\n+\n+            if field.name == \"channel_uid\":\n+                channel_uid = await field.text()\n+                continue\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+            \n+            file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n+            files.append(file_path)\n+ \n+            async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+\n+            drive_item = await self.services.drive_item.create(drive[\"uid\"],filename,str(file_path.absolute()),file_path.stat().st_size,file_path.suffix)\n+\n+            await self.services.chat.send(self.request.session.get(\"uid\"),channel_uid,f\"![{filename}](/drive.bin/{drive_item['uid']})\")\n+            print(drive_item,flush=True)\n+\n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files],\"channel_uid\":channel_uid})\n+\n+\n+"}
{"repo": ".", "date": "2025-02-05", "line": "feat: Added :snek1: emoji support", "commit": "b6185a95f3fcbf539ec0ba767d4c0923092f8e82", "diff": "commit b6185a95f3fcbf539ec0ba767d4c0923092f8e82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 5 19:11:11 2025 +0100\n\n    Added :snek1:\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 5a147ae..2d5b6d9 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -27,7 +27,7 @@ dependencies = [\n     \"requests\",\n     \"asyncssh\",\n     \"emoji\",\n-    \"pywebpush\",\n-    \"aiofiles\"\n+    \"aiofiles\",\n+    \"PyJWT\"\n ]\n \ndiff --git a/src/snek/static/emoji/snek1.gif b/src/snek/static/emoji/snek1.gif\nnew file mode 100644\nindex 0000000..9d34458\nBinary files /dev/null and b/src/snek/static/emoji/snek1.gif differ\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex ed153f0..89496a0 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -7,7 +7,7 @@ from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n-\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n \n def set_link_target_blank(text):\n     soup = BeautifulSoup(text, 'html.parser')\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex bcff7fb..c271db3 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@"}
{"repo": ".", "date": "2025-02-06", "line": "feat: Add push.js and handle RPC errors gracefully", "commit": "203314b209030f297cd888685bb68721bc21c61b", "diff": "commit 203314b209030f297cd888685bb68721bc21c61b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 6 17:31:14 2025 +0100\n\n    Updated False if failed login.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0aaffc9..4d091cf 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,6 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <script src=\"/push.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 531aa40..a144df2 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -127,13 +127,15 @@ class RPCView(BaseView):\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n+                success = True \n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    success = False \n                     print(result,flush=True)\n-                await self._send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+                await self._send_json({\"callId\":call_id,\"success\":success,\"data\":result})\n             except Exception as ex:\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Improved code highlighting fallback for unknown languages", "commit": "386d9c3aaee80115241866ae72df9fad3ea3c714", "diff": "commit 386d9c3aaee80115241866ae72df9fad3ea3c714\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 17:49:29 2025 +0100\n\n    Fixed markdown.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 23d0656..198f589 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -28,9 +28,11 @@ class MarkdownRenderer(HTMLRenderer):\n         if not lang:\n             lang = info\n         if not lang:\n-            return f\"<div>{code}</div>\"\n+            lang = 'bash'\n         lexer = get_lexer_by_name(lang, stripall=True)\n+        if not lexer:\n+            return f\"<pre>{code}</pre>\"\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         return highlight(code, lexer, formatter)"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Handle cases where code highlighting fails", "commit": "d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e", "diff": "commit d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:02:10 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 198f589..1f01354 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -31,10 +31,11 @@ class MarkdownRenderer(HTMLRenderer):\n             lang = 'bash'\n         lexer = get_lexer_by_name(lang, stripall=True)\n-        if not lexer:\n-            return f\"<pre>{code}</pre>\"\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        return highlight(code, lexer, formatter)\n+        result = highlight(code, lexer, formatter)\n+        if not result:\n+            return f\"<pre>{code}</pre>\"\n+        return result \n \n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Escape code blocks in markdown renderer", "commit": "a301e2c5bfb8286f63a48c2860162780f95e820d", "diff": "commit a301e2c5bfb8286f63a48c2860162780f95e820d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:06:48 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 1f01354..97416a3 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-\n+from html.parser import escape\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n@@ -34,7 +34,7 @@ class MarkdownRenderer(HTMLRenderer):\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n         if not result:\n-            return f\"<pre>{code}</pre>\"\n+            return f\"<pre>{escape(code)}</pre>\"\n         return result \n \n     def render(self):"}
{"repo": ".", "date": "2025-02-07", "line": "refactor: Updated markdown import to use html.escape", "commit": "cfa2af61b81b613bf2b8177b8acff1ea8b7c8576", "diff": "commit cfa2af61b81b613bf2b8177b8acff1ea8b7c8576\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:09:15 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 97416a3..f0b6e25 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-from html.parser import escape\n+from html import escape\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Add code highlighting with language detection", "commit": "51f1b1d86e4813c10e2750f0771c1bdcc1274bfb", "diff": "commit 51f1b1d86e4813c10e2750f0771c1bdcc1274bfb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:21:41 2025 +0100\n\n    Changed markdown.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex f0b6e25..ca603d8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -24,17 +24,20 @@ class MarkdownRenderer(HTMLRenderer):\n     def _escape(self, str):\n \n+    def get_lexer(self, lang, default='bash'):\n+        try:\n+            return get_lexer_by_name(lang, stripall=True)\n+        except:\n+            return get_lexer_by_name(default, stripall=True)\n+    \n     def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n             lang = 'bash'\n-        lexer = get_lexer_by_name(lang, stripall=True)\n+        lexer = self.get_lexer(lang)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n-        if not result:\n-            return f\"<pre>{escape(code)}</pre>\"\n         return result \n \n     def render(self):"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Added emoticons to navigation links", "commit": "9840c8eb03f969330583e9c3a7b28dbb5548f7d6", "diff": "commit 9840c8eb03f969330583e9c3a7b28dbb5548f7d6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 19:42:03 2025 +0100\n\n    Applied emoticons.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4d091cf..4202b88 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -24,10 +24,11 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n-      <a href=\"/web.html\">Home</a>\n-      <a href=\"/logout.html\">Logout</a>\n+      <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/web.html\">\ud83d\udc65</a>\n+      <a href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n   </header>\n   <main>"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Prevent memory leaks by closing and nulling websocket on disconnect", "commit": "7ca2bc5776213828a31c7fc237784a0a73c6f759", "diff": "commit 7ca2bc5776213828a31c7fc237784a0a73c6f759\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 19:45:05 2025 +0100\n\n    Removed double sockets.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a35f153..2f3e610 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -247,6 +247,8 @@ class Socket extends EventHandler {\n         console.info(\"Connection lost. Reconnecting.\");\n         this.isConnected = false;\n         this.isConnecting = false;\n+        this.ws.close();\n+        this.ws = null;\n         this.ensureConnection().then(() => {\n             console.info(\"Reconnected.\");\n         });"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Allow underscores in usernames and add user search functionality", "commit": "f291c0f2e4081fde4ed55d6cc25fdcbb1952af70", "diff": "commit f291c0f2e4081fde4ed55d6cc25fdcbb1952af70\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:07:04 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 1384b8f..9331fae 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -20,7 +20,7 @@ class RegisterForm(Form):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n         place_holder=\"Username\",\n         type=\"text\",\n     )\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex eb14ee7..1cad3ee 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -4,6 +4,15 @@ from snek.system.service import BaseService\n \n class UserService(BaseService):\n     mapper_name = \"user\"\n+    \n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):\n+            results.append(result)\n+        return results\n \n     async def validate_login(self, username, password):\n         model = await self.get(username=username)\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4202b88..0225965 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,65 +1,4 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Snek</title>\n-  <style>{{highlight_styles}}</style>\n-  <script src=\"/push.js\"></script>\n-  <script src=\"/upload-button.js\"></script>\n-  <script src=\"/html-frame.js\"></script>\n-  <script src=\"/schedule.js\"></script>\n-  <script src=\"/app.js\"></script>\n-  <script src=\"/models.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <script src=\"/message-list-manager.js\"></script>\n-  <script src=\"/chat-input.js\"></script>\n-  <script src=\"/chat-window.js\"></script>\n-  <link rel=\"stylesheet\" href=\"/base.css\">\n-  <link rel=\"manifest\" href=\"/manifest.json\" />\n-  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n-\n-</head>\n-<body>\n-  <header>\n-    <div class=\"logo\">Snek</div>\n-    <nav>\n-      <a href=\"/web.html\">\ud83c\udfe0</a>\n-      <a href=\"/web.html\">\ud83d\udc65</a>\n-      <a href=\"/logout.html\">\ud83d\udd12</a>\n-    </nav>\n-  </header>\n-  <main>\n-    <aside class=\"sidebar\">\n-      <h2>Chat Rooms</h2>\n-      <ul>\n-       \n-      </ul>\n-    </aside>\n+{% extends \"app.html\" %} \n+{% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\n-  </main>\n-  <script>\n-let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-           \n-            const button = document.getElementById(\"install-button\")\n-            button.addEventListener(\"click\", async ()=>{  \n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n-            })\n-            button.style.display = 'inline-block'\n-            \n-          });\n-        ;\n-    </script>\n-</body>\n-</html>\n+{% endblock %}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex a144df2..d4b2e59 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -46,6 +46,11 @@ class RPCView(BaseView):\n                 await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n        \n             return record \n+\n+        async def search_user(self, query): \n+            self._require_login()\n+            return [user['username'] for user in await self.services.user.search(query)]\n+\n         async def get_user(self, user_uid):\n             self._require_login()\n             if not user_uid:"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Initial app template with basic structure and links", "commit": "fcb05903f3f583ce8532d65ee7edf1ad8df91df4", "diff": "commit fcb05903f3f583ce8532d65ee7edf1ad8df91df4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:09:05 2025 +0100\n\n    Fix template.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nnew file mode 100644\nindex 0000000..2ff66eb\n--- /dev/null\n+++ b/src/snek/templates/app.html\n@@ -0,0 +1,68 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Snek</title>\n+  <style>{{highlight_styles}}</style>\n+  <script src=\"/push.js\"></script>\n+  <script src=\"/upload-button.js\"></script>\n+  <script src=\"/html-frame.js\"></script>\n+  <script src=\"/schedule.js\"></script>\n+  <script src=\"/app.js\"></script>\n+  <script src=\"/models.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n+  <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-input.js\"></script>\n+  <script src=\"/chat-window.js\"></script>\n+  <link rel=\"stylesheet\" href=\"/base.css\">\n+  <link rel=\"manifest\" href=\"/manifest.json\" />\n+  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Snek</div>\n+    <nav>\n+      <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/web.html\">\ud83d\udc65</a>\n+      <a href=\"/logout.html\">\ud83d\udd12</a>\n+    </nav>\n+  </header>\n+  <main>\n+    {% block sidebar %}\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+       \n+      </ul>\n+    </aside>\n+    {% endblock %}\n+    {% block main %}\n+    <chat-window class=\"chat-area\"></chat-window>\n+    {% endblock %}\n+  </main>\n+  <script>\n+let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+            alert(\"Jaaah\") \n+            const button = document.getElementById(\"install-button\")\n+            button.addEventListener(\"click\", async ()=>{  \n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+            })\n+            button.style.display = 'inline-block'\n+            \n+          });\n+        ;\n+    </script>\n+</body>\n+</html>"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Relaxed username and password regex constraints", "commit": "8d0d709e18be0177b99f76f320eeb02b70bb41b0", "diff": "commit 8d0d709e18be0177b99f76f320eeb02b70bb41b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:14:37 2025 +0100\n\n    Fix template.\n\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 2966053..ef13d67 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -23,14 +23,14 @@ class LoginForm(Form):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n         place_holder=\"Username\",\n         type=\"text\",\n     )\n     password = AuthField(\n         name=\"password\",\n         required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        min_length=1,\n         type=\"password\",\n         place_holder=\"Password\",\n     )\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 9331fae..b105696 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -34,7 +34,7 @@ class RegisterForm(Form):\n     password = FormInputElement(\n         name=\"password\",\n         required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        min_length=1,\n         type=\"password\",\n         place_holder=\"Password\",\n     )\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 2910a6d..cac9aaf 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -15,7 +15,7 @@ class UserModel(BaseModel):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     color = ModelField(\n         name =\"color\",\n@@ -28,4 +28,4 @@ class UserModel(BaseModel):\n         required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n-    password = ModelField(name=\"password\", required=True, regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n+    password = ModelField(name=\"password\", required=True, min_length=1)"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Added user search functionality with a dedicated view and template", "commit": "d7b943dc8c8f485c975730d6054e32e67db36c91", "diff": "commit d7b943dc8c8f485c975730d6054e32e67db36c91\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:31:03 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 8cfed8f..6d0d1e6 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -27,6 +27,7 @@ from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n+from snek.view.search_user import SearchUserView \n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -83,6 +84,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n         self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n+        self.router.add_view(\"/search-user.html\", SearchUserView)\n+        self.router.add_view(\"/search-user.json\", SearchUserView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/static/push.js b/src/snek/static/push.js\nnew file mode 100644\nindex 0000000..806a51e\n--- /dev/null\n+++ b/src/snek/static/push.js\n@@ -0,0 +1,30 @@\n+this.onpush = (event) => {\n+            console.log(event.data);\n+        };\n+\n+        navigator.serviceWorker\n+            .register(\"service-worker.js\")\n+            .then((serviceWorkerRegistration) => {\n+                serviceWorkerRegistration.pushManager.subscribe().then(\n+                    (pushSubscription) => {\n+                        const subscriptionObject = {\n+                            endpoint: pushSubscription.endpoint,\n+                            keys: {\n+                                p256dh: pushSubscription.getKey('p256dh'),\n+                                auth: pushSubscription.getKey('auth'),\n+                            },\n+                            encoding: PushManager.supportedContentEncodings,\n+                        };\n+                        console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);\n+                    },\n+                    (error) => {\n+                        console.error(error);\n+                    },\n+                );\n+            });\ndiff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js\nnew file mode 100644\nindex 0000000..dfdc493\n--- /dev/null\n+++ b/src/snek/static/service-worker.js\n@@ -0,0 +1,30 @@\n+self.addEventListener(\"install\", (event) => {\n+  console.log(\"Service worker installed\");\n+});\n+\n+self.addEventListener(\"push\",  (event) => {\n+  if (!(self.Notification && self.Notification.permission === \"granted\")) {\n+    return;\n+  }\n+  console.log(\"Received a push message\", event);\n+\n+  const data = event.data?.json() ?? {};\n+  const title = data.title || \"Something Has Happened\";\n+  const message =\n+    data.message || \"Here's something you might want to check out.\";\n+  const icon = \"images/new-notification.png\";\n+\n+  event.waitUntil(self.registration.showNotification(title, {\n+    body: message,\n+    tag: \"simple-push-demo-notification\",\n+    icon,\n+  }));\n+\n+});\n+\n+self.addEventListener(\"notificationclick\", (event) => {\n+    console.log(\"Notification click Received.\", event);\n+    event.notification.close();\n+    event.waitUntil(clients.openWindow(\n+});\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 2ff66eb..fe38358 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -24,6 +24,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a href=\"/web.html\">\ud83d\udc65</a>\n@@ -52,7 +53,7 @@ let installPrompt = null\n         window.addEventListener(\"beforeinstallprompt\", async(event) => {\n             event.preventDefault();\n             installPrompt = event;\n+            document.addEventListener(\"DOMContentLoaded\", () => {\n             alert(\"Jaaah\") \n             const button = document.getElementById(\"install-button\")\n             button.addEventListener(\"click\", async ()=>{  \n@@ -61,7 +62,8 @@ let installPrompt = null\n             })\n             button.style.display = 'inline-block'\n             \n-          });\n+            })\n+        });\n         ;\n     </script>\n </body>\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\nnew file mode 100644\nindex 0000000..9eee36c\n--- /dev/null\n+++ b/src/snek/templates/search-user.html\n@@ -0,0 +1,8 @@\n+{% extends \"app.html\" %}\n+\n+{% block title %}Search{% endblock %}\n+\n+{% block main %}    \n+    <h1>Search user</h1>\n+    <generic-form class=\"center\" url=\"/search_user.json\"></generic-form>\n+{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nnew file mode 100644\nindex 0000000..4fa890c\n--- /dev/null\n+++ b/src/snek/view/search_user.py\n@@ -0,0 +1,21 @@\n+from aiohttp import web\n+\n+from snek.form.search_user import SearchUserForm\n+from snek.system.view import BaseFormView\n+\n+\n+class SearchUserView(BaseFormView):\n+    form = SearchUserForm\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\"login.html\")\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            \n+            return {\"redirect_url\": \"/search-user.html?query=\" + form.query.value}\n+        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Implemented search user form", "commit": "49eb76dc8b93cd422a9fda40cece480a573b8524", "diff": "commit 49eb76dc8b93cd422a9fda40cece480a573b8524\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:33:02 2025 +0100\n\n    Added search form.\n\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nnew file mode 100644\nindex 0000000..6f431f0\n--- /dev/null\n+++ b/src/snek/form/search_user.py\n@@ -0,0 +1,19 @@\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SearchUserForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n+\n+    username = FormInputElement(\n+        name=\"username\",\n+        required=True,\n+        min_length=1,\n+        max_length=128,\n+        place_holder=\"Username\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n+    )\n+"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Prevent potentially harmful queries via input sanitization", "commit": "60ca3ec7918073a2fb3ebe81e9ea733225391d99", "diff": "commit 60ca3ec7918073a2fb3ebe81e9ea733225391d99\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:59:38 2025 +0100\n\n    Added search form.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d4b2e59..2a1963a 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -113,7 +113,7 @@ class RPCView(BaseView):\n             print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n+            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase or 'replace' in lowercase or 'insert' in lowercase or 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n             records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Improved styling and form handling for user search\n\nThis commit enhances the user search functionality with improved CSS styling, including bold text and spacing for better readability. It also adds a \"Back\" button to the search form and updates the form submission logic to handle search queries correctly. Additionally, the HTML template has been updated to display search results in a more user-friendly format.\n", "commit": "5154811b29ced87375ac457fafeb25305f64a954", "diff": "commit 5154811b29ced87375ac457fafeb25305f64a954\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 21:54:46 2025 +0100\n\n    CSS Fixes.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 5db5aa9..bec7c5b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -64,10 +64,34 @@ header nav a {\n   transition: color 0.3s;\n }\n \n+\n header nav a:hover {\n }\n \n+a {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+}\n+\n+.chat-area ul {\n+    margin: 0px;\n+    padding: 0px;\n+li {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+    list-style: none;\n+      padding: 0;\n+      margin: 0; \n+      font-size: 1.5em;\n+    a {\n+        text-decoration: none;\n+    }\n+  \n+}\n+}\n main {\n   display: flex;\n   flex: 1;\n@@ -198,6 +222,16 @@ message-list {\n }\n \n+input[type=\"text\"] {\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n .chat-input textarea {\n   flex: 1;\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 948db17..310f340 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -55,9 +55,16 @@ class FancyButton extends HTMLElement {\n         this.shadowRoot.appendChild(this.container);\n \n         this.url = this.getAttribute('url');\n+       \n+\n         this.value = this.getAttribute('value');\n         this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")));\n         this.buttonElement.addEventListener(\"click\", () => {\n+            if(this.url == 'submit'){\n+                this.closest('form').submit()\n+                return\n+            }    \n+        \n             if (this.url === \"/back\" || this.url === \"/back/\") {\n                 window.history.back();\n             } else if (this.url) {\n@@ -67,4 +74,4 @@ class FancyButton extends HTMLElement {\n     }\n }\n \n-customElements.define(\"fancy-button\", FancyButton);\n\\ No newline at end of file\n+customElements.define(\"fancy-button\", FancyButton);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex fe38358..81d5611 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -6,7 +6,9 @@\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n   <script src=\"/push.js\"></script>\n+  <script src=\"/fancy-button.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n+  <script src=\"/generic-form.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\nindex 9eee36c..34fe525 100644\n--- a/src/snek/templates/search-user.html\n+++ b/src/snek/templates/search-user.html\n@@ -3,6 +3,24 @@\n {% block title %}Search{% endblock %}\n \n {% block main %}    \n-    <h1>Search user</h1>\n-    <generic-form class=\"center\" url=\"/search_user.json\"></generic-form>\n+\n+    <section class=\"chat-area\">\n+        <div class=\"chat-header\"><h2>Search user</h2></div>\n+        <div class=\"chat-messages\">\n+        <form method=\"get\" action=\"/search-user.html\">\n+            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n+            <fancy-button size=\"auto\"  text=\"Back\" url=\"submit\"></fancy-button>\n+        </form>\n+        <ul>\n+        {% for user in users %}\n+    <li>\n+        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+    </li>\n+    \n+    {% endfor %}\n+    </ul> \n+\n+ \n+</div>\n+    </section>\n {% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 4fa890c..15e9c33 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -8,14 +8,20 @@ class SearchUserView(BaseFormView):\n     form = SearchUserForm\n \n     async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n+        users = []\n+        query = self.request.query.get(\"query\")\n+        if query:\n+            users = await self.app.services.user.search(query)\n+            print(users,flush=True) \n+\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\")\n+        return await self.render_template(\"search-user.html\",dict(users=users,query=query or ''))\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            \n-            return {\"redirect_url\": \"/search-user.html?query=\" + form.query.value}\n+            print(\"YEAAAH\\n\") \n+            return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n         return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Adjusted chat message styling", "commit": "a8fea31a326c7b9868dde866be553bb9f84eee88", "diff": "commit a8fea31a326c7b9868dde866be553bb9f84eee88\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 22:03:26 2025 +0100\n\n    Fixed..\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex bec7c5b..aa6a06d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -156,6 +156,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n+  \n   padding: 10px;\n   height: 200px;\n@@ -172,7 +173,7 @@ message-list {\n   align-items: flex-start;\n   margin-bottom: 0px;\n   padding: 5px;\n+  \n   border-radius: 8px;\n }\n@@ -190,7 +191,6 @@ message-list {\n   align-items: center;\n   margin-right: 15px;\n }\n-\n .chat-messages .message .message-content {\n   flex: 1;\n }"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Optimize database performance with WAL mode", "commit": "06b539b8845c49ec6d2789876ef4069b8df77117", "diff": "commit 06b539b8845c49ec6d2789876ef4069b8df77117\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 23:48:42 2025 +0100\n\n    Fixed..\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6d0d1e6..aaf1029 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -58,6 +58,8 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n+        self.db.query(\"PRAGMA journal_mode=WAL\")\n+        self.db.query(\"PRAGMA syncnorm=off\")\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added no-select class to prevent text selection", "commit": "b169fa4792e02303ea0f61e5d83b3993a8f72f05", "diff": "commit b169fa4792e02303ea0f61e5d83b3993a8f72f05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:31:34 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa6a06d..e57b944 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -63,7 +63,12 @@ header nav a {\n   font-size: 1em;\n   transition: color 0.3s;\n }\n-\n+.no-select {\n+    }\n \n header nav a:hover {\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 17f3066..91d80d3 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -74,6 +74,7 @@ class MessageListElement extends HTMLElement {\n \n         const avatar = document.createElement(\"div\");\n         avatar.classList.add(\"avatar\");\n+        avatar.classList.add(\"no-select\");\n         avatar.style.backgroundColor = message.color;\n         avatar.style.color = \"black\";\n         avatar.innerText = message.user_nick[0];\n@@ -166,4 +167,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\n+customElements.define('message-list', MessageListElement);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 81d5611..8d2adca 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -25,23 +25,23 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n-      <a href=\"/web.html\">\ud83c\udfe0</a>\n-      <a href=\"/search-user.html\">\ud83d\udd0d</a>\n-      <a href=\"/web.html\">\ud83d\udc65</a>\n-      <a href=\"/logout.html\">\ud83d\udd12</a>\n+      <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n+      <a class=\"no-select\" href=\"/web.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n   </header>\n   <main>\n     {% block sidebar %}\n     <aside class=\"sidebar\">\n-      <h2>Chat Rooms</h2>\n+      <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n        \n       </ul>\n     </aside>"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection on header elements", "commit": "ad4847a78e2945fe4f57ae16262e0c2a91a804f5", "diff": "commit ad4847a78e2945fe4f57ae16262e0c2a91a804f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:34:02 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0b341dc..1e6f9fb 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -41,6 +41,7 @@ class ChatWindowElement extends HTMLElement {\n \n         const chatTitle = document.createElement('h2');\n         chatTitle.classList.add(\"chat-title\");\n+        chatTitle.classList.add(\"no-select\");\n         chatTitle.innerText = \"Loading...\";\n         chatHeader.appendChild(chatTitle);\n         this.container.appendChild(chatHeader);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 8d2adca..df2c555 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -23,7 +23,7 @@\n </head>\n <body>\n   <header>\n-    <div class=\"logo\">Snek</div>\n+    <div class=\"no-select logo\">Snek</div>\n     <nav>\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Prevent selection on navigation elements", "commit": "bda5cfd52d5272742d147e93b506b87eeee04e1e", "diff": "commit bda5cfd52d5272742d147e93b506b87eeee04e1e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:38:01 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex df2c555..7412c17 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -24,7 +24,7 @@\n <body>\n   <header>\n     <div class=\"no-select logo\">Snek</div>\n-    <nav>\n+    <nav class=\"no-select\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Updated template name in search user view", "commit": "afa40ada778c5d4102bce19312456adca51f70d0", "diff": "commit afa40ada778c5d4102bce19312456adca51f70d0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:42:34 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\ndeleted file mode 100644\nindex 34fe525..0000000\n--- a/src/snek/templates/search-user.html\n+++ /dev/null\n@@ -1,26 +0,0 @@\n-{% extends \"app.html\" %}\n-\n-{% block title %}Search{% endblock %}\n-\n-{% block main %}    \n-\n-    <section class=\"chat-area\">\n-        <div class=\"chat-header\"><h2>Search user</h2></div>\n-        <div class=\"chat-messages\">\n-        <form method=\"get\" action=\"/search-user.html\">\n-            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n-            <fancy-button size=\"auto\"  text=\"Back\" url=\"submit\"></fancy-button>\n-        </form>\n-        <ul>\n-        {% for user in users %}\n-    <li>\n-        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n-    </li>\n-    \n-    {% endfor %}\n-    </ul> \n-\n- \n-</div>\n-    </section>\n-{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 15e9c33..292e135 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -18,7 +18,7 @@ class SearchUserView(BaseFormView):\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"search-user.html\",dict(users=users,query=query or ''))\n+        return await self.render_template(\"search_user.html\",dict(users=users,query=query or ''))\n \n     async def submit(self, form):\n         if await form.is_valid:"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Implemented user search functionality with basic UI", "commit": "a42c2bdf5d2cee53c16e8dc123fc4473107ef203", "diff": "commit a42c2bdf5d2cee53c16e8dc123fc4473107ef203\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:42:50 2025 +0100\n\n    Added search user.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nnew file mode 100644\nindex 0000000..ba5b51b\n--- /dev/null\n+++ b/src/snek/templates/search_user.html\n@@ -0,0 +1,26 @@\n+{% extends \"app.html\" %}\n+\n+{% block title %}Search{% endblock %}\n+\n+{% block main %}    \n+\n+    <section class=\"chat-area\">\n+        <div class=\"chat-header\"><h2>Search user</h2></div>\n+        <div class=\"chat-messages\">\n+        <form method=\"get\" action=\"/search-user.html\">\n+            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n+            <fancy-button size=\"auto\"  text=\"Search\" url=\"submit\"></fancy-button>\n+        </form>\n+        <ul>\n+        {% for user in users %}\n+    <li>\n+        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+    </li>\n+    \n+    {% endfor %}\n+    </ul> \n+\n+ \n+</div>\n+    </section>\n+{% endblock %}"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent SQL injection by enhancing security checks", "commit": "e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f", "diff": "commit e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 02:07:29 2025 +0100\n\n    Updated sql security.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 2a1963a..158ed21 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -113,7 +113,7 @@ class RPCView(BaseView):\n             print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase or 'replace' in lowercase or 'insert' in lowercase or 'select' not in lowercase:\n+            if any([\"drop\" in lowercase, \"alter\" in lowercase,\"update\" in lowercase, \"delete\" in lowercase, 'replace' in lowercase , 'insert' in lowercase , 'truncate' in lowercase , 'select' not in lowercase]):\n                 raise Exception(\"Not allowed\")\n             records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor view files for consistency and improved structure", "commit": "a3cec5bce0386c8c3012262aa4a582241786220d", "diff": "commit a3cec5bce0386c8c3012262aa4a582241786220d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 02:25:06 2025 +0100\n\n    Branding.\n\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 762fc8e..b03f922 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,13 +1,37 @@\n-from snek.system.view import BaseView\n \n \n-class AboutHTMLView(BaseView):\n \n+\n+from snek.system.view import BaseView\n+\n+class AboutHTMLView(BaseView):\n+   \n     async def get(self):\n         return await self.render_template(\"about.html\")\n \n-\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n+        return await self.render_template(\"about.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex 519a0eb..592d1a2 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,13 +1,35 @@\n+  \n+  \n+  \n+  \n+  \n from snek.system.view import BaseView\n \n-\n class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n \n-\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n+        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex bd91dc8..3e62518 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,7 +1,17 @@\n-from snek.system.view import BaseView\n \n \n-class IndexView(BaseView):\n+\n+\n+\n \n+\n+\n+from snek.system.view import BaseView\n+\n+class IndexView(BaseView):\n     async def get(self):\n-        return await self.render_template(\"index.html\")\n+        return await self.render_template(\"index.html\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex db5be55..0c2359d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,9 +1,15 @@\n-from aiohttp import web\n+\n+\n \n+\n+from aiohttp import web\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n-\n class LoginView(BaseFormView):\n     form = LoginForm\n \n@@ -15,13 +21,14 @@ class LoginView(BaseFormView):\n         return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n-        if await form.is_valid:\n-            user = await self.services.user.get(username=form['username'],deleted_at=None)\n+        if await form.is_valid():\n+            user = await self.services.user.get(username=form['username'], deleted_at=None)\n             await self.services.user.save(user)\n-            self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = user['username']\n-            self.session[\"uid\"] = user[\"uid\"]\n-            self.session[\"color\"] = user[\"color\"]\n+            self.session.update({\n+                \"logged_in\": True,\n+                \"username\": user['username'],\n+                \"uid\": user[\"uid\"],\n+                \"color\": user[\"color\"]\n+            })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex f0efce9..230d334 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,3 +1,12 @@\n+\n+\n+\n+\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n@@ -6,9 +15,9 @@ class LoginFormView(BaseFormView):\n     form = LoginForm\n \n     async def submit(self, form):\n-        if await form.is_valid:\n+        if await form.is_valid():\n             self.session[\"logged_in\"] = True\n             self.session[\"username\"] = form.username.value\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex eb5c1ae..57b92a3 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,10 +1,38 @@\n-from aiohttp import web\n+\n+\n+\n+\n \n+\n+\n+\n+from aiohttp import web\n from snek.system.view import BaseView\n \n \n class LogoutView(BaseView):\n-\n     redirect_url = \"/\"\n     login_required = True\n \n@@ -24,4 +52,4 @@ class LogoutView(BaseView):\n             del self.session[\"username\"]\n         except KeyError:\n             pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex c29d855..fdcc9ad 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,11 +1,16 @@\n-from aiohttp import web\n+\n+\n+\n \n+from aiohttp import web\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n-\n class RegisterView(BaseFormView):\n-\n     form = RegisterForm\n \n     async def get(self):\n@@ -23,4 +28,4 @@ class RegisterView(BaseFormView):\n         self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n         self.request.session[\"color\"] = result[\"color\"]\n-        return {\"redirect_url\": \"/web.html\"}\n+        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 89fa3ac..858edad 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,3 +1,30 @@\n+\n+\n+\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n@@ -10,7 +37,7 @@ class RegisterFormView(BaseFormView):\n             form.email.value, form.username.value, form.password.value\n         )\n         self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"usernmae\"]\n+        self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n \n-        return {\"redirect_url\": \"/web.html\"}\n+        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 158ed21..53e5aab 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,3 +1,12 @@\n+\n+\n+\n+\n+\n from aiohttp import web  \n from snek.system.view import BaseView\n import traceback\n@@ -6,7 +15,7 @@ import json\n class RPCView(BaseView):\n \n     class RPCApi:\n-        def __init__(self,view, ws):\n+        def __init__(self, view, ws):\n             self.view = view \n             self.app = self.view.app\n             self.services = self.app.services\n@@ -15,7 +24,6 @@ class RPCView(BaseView):\n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")\n-            \n \n         @property \n         def request(self):\n@@ -42,9 +50,8 @@ class RPCView(BaseView):\n             del record['password']\n             del record['deleted_at']\n             await self.services.socket.add(self.ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n-       \n+            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"])\n             return record \n \n         async def search_user(self, query): \n@@ -59,66 +66,59 @@ class RPCView(BaseView):\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            if not user_uid == user[\"uid\"]:\n+            if user_uid != user[\"uid\"]:\n                 del record['email']\n             return record \n-        async def get_messages(self, channel_uid,offset=0):\n+\n+        async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-                \n+            async for message in self.services.channel_message.query(\"SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 60\"):\n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:\n-                    print(\"User not found!\",flush= True)\n+                    print(\"User not found!\", flush=True)\n                     continue\n-\n-                messages.insert(0,dict(\n-                    uid=message[\"uid\"],\n-                    color=user['color'],\n-                    user_uid=message[\"user_uid\"],\n-                    channel_uid=message[\"channel_uid\"],\n-                    user_nick=user['nick'],\n-                    message=message[\"message\"],\n-                    created_at=message[\"created_at\"],\n-                    html=message['html'],\n-                    username=user['username'] \n-                ))\n-                print(\"Response messages:\",messages,flush=True)\n+                messages.insert(0, {\n+                    \"uid\": message[\"uid\"],\n+                    \"color\": user['color'],\n+                    \"user_uid\": message[\"user_uid\"],\n+                    \"channel_uid\": message[\"channel_uid\"],\n+                    \"user_nick\": user['nick'],\n+                    \"message\": message[\"message\"],\n+                    \"created_at\": message[\"created_at\"],\n+                    \"html\": message['html'],\n+                    \"username\": user['username'] \n+                })\n             return messages\n-        \n+\n         async def get_channels(self):\n             self._require_login()\n             channels = []\n-            async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n-                channels.append(dict(\n-                    name=subscription[\"label\"],\n-                    uid=subscription[\"channel_uid\"],\n-                    is_moderator=subscription[\"is_moderator\"],\n-                    is_read_only=subscription[\"is_read_only\"]\n-                ))\n+            async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n+                channels.append({\n+                    \"name\": subscription[\"label\"],\n+                    \"uid\": subscription[\"channel_uid\"],\n+                    \"is_moderator\": subscription[\"is_moderator\"],\n+                    \"is_read_only\": subscription[\"is_read_only\"]\n+                })\n             return channels\n \n         async def send_message(self, room, message):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid,room,message)\n+            await self.services.chat.send(self.user_uid, room, message)\n             return True \n-        \n \n-        async def echo(self,*args):\n+        async def echo(self, *args):\n             self._require_login()\n             return args\n \n-        async def query(self,*args):\n+        async def query(self, *args):\n             self._require_login()\n-            print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if any([\"drop\" in lowercase, \"alter\" in lowercase,\"update\" in lowercase, \"delete\" in lowercase, 'replace' in lowercase , 'insert' in lowercase , 'truncate' in lowercase , 'select' not in lowercase]):\n+            if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) async for record in self.services.channel.query(args[0])]\n-            return records \n-\n-\n+            return [dict(record) async for record in self.services.channel.query(args[0])]\n \n         async def __call__(self, data):\n             try:\n@@ -126,58 +126,45 @@ class RPCView(BaseView):\n                 method_name = data.get(\"method\")\n                 if method_name.startswith(\"_\"):\n                     raise Exception(\"Not allowed\")\n-                args = data.get(\"args\")\n-                if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                    return await self._send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n-                method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+                args = data.get(\"args\") or []\n+                if hasattr(super(), method_name) or not hasattr(self, method_name):\n+                    return await self._send_json({\"callId\": call_id, \"data\": \"Not allowed\"})\n+                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n                 success = True \n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False \n-                    print(result,flush=True)\n-                await self._send_json({\"callId\":call_id,\"success\":success,\"data\":result})\n+                await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n-                await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+                await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n-        async def _send_json(self,obj):\n-            await self.ws.send_str(json.dumps(obj,default=str))\n+        async def _send_json(self, obj):\n+            await self.ws.send_str(json.dumps(obj, default=str))\n \n-        async def call_ping(self,callId,*args):\n+        async def call_ping(self, callId, *args):\n             return {\"pong\": args}\n \n-\n     async def get(self):\n-\n-        \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\") is True:\n+        if self.request.session.get(\"logged_in\"):\n             await self.services.socket.add(ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n-        rpc = RPCView.RPCApi(self,ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"])\n+        rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n-            print(msg,flush=True)\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     await rpc(msg.json())\n                 except Exception as ex:\n-                    print(ex,flush=True)\n-                    print(traceback.format_exc(),flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n-                print(f\"WebSocket exception {ws.exception()}\")\n                 pass \n             elif msg.type == web.WSMsgType.CLOSE:\n                 pass \n-        print(\"WebSocket connection closed\")\n-        return ws\n+        return ws\n\\ No newline at end of file\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 292e135..04e9080 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,5 +1,34 @@\n-from aiohttp import web\n+\n+\n+\n \n+\n+from aiohttp import web\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n \n@@ -8,20 +37,19 @@ class SearchUserView(BaseFormView):\n     form = SearchUserForm\n \n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n             users = await self.app.services.user.search(query)\n-            print(users,flush=True) \n+            print(users, flush=True)\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"search_user.html\",dict(users=users,query=query or ''))\n+\n+        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or ''})\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            print(\"YEAAAH\\n\") \n+            print(\"YES\\n\")\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 04ea4d9..9428f08 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,18 +1,44 @@\n-from snek.system.view import BaseView\n+\n+\n+\n \n \n+from snek.system.view import BaseView\n+\n class StatusView(BaseView):\n     async def get(self):\n-\n         memberships = []\n         user = {}\n-\n-        if self.session.get(\"uid\"):\n-            user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        \n+        user_id = self.session.get(\"uid\")\n+        if user_id:\n+            user = await self.app.services.user.get(uid=user_id)\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n+            \n             async for model in self.app.services.channel_member.find(\n-                user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False\n+                user_uid=user_id, deleted_at=None, is_banned=False\n             ):\n                 channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n                 memberships.append(\n@@ -33,7 +59,7 @@ class StatusView(BaseView):\n                 \"email\": user[\"email\"],\n                 \"nick\": user[\"nick\"],\n                 \"uid\": user[\"uid\"],\n-                \"color\": user['color'],\n+                \"color\": user[\"color\"],\n                 \"memberships\": memberships,\n             }\n \n@@ -44,4 +70,4 @@ class StatusView(BaseView):\n                     self.app.cache.cache, None\n                 ),\n             }\n-        )\n+        )\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex ff208e9..2d7d98b 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,8 +1,20 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n-import aiofiles \n+import aiofiles\n import pathlib\n from aiohttp import web\n-import uuid \n+import uuid\n \n UPLOAD_DIR = pathlib.Path(\"./drive\")\n \n@@ -11,24 +23,23 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-        \n-        print(await drive_item.to_json(),flush=True)\n-        return web.FileResponse(drive_item[\"path\"])       \n+\n+        print(await drive_item.to_json(), flush=True)\n+        return web.FileResponse(drive_item[\"path\"])\n \n     async def post(self):\n         reader = await self.request.multipart()\n-        files = []  \n+        files = []\n \n         UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n \n-        channel_uid = None \n+        channel_uid = None\n \n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n-        print(str(drive),flush=True)\n+        print(str(drive), flush=True)\n \n         while field := await reader.next():\n-\n             if field.name == \"channel_uid\":\n                 channel_uid = await field.text()\n                 continue\n@@ -36,21 +47,21 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n-            \n+\n             file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n             files.append(file_path)\n- \n+\n             async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n+            drive_item = await self.services.drive_item.create(\n+                drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n+            )\n \n-            drive_item = await self.services.drive_item.create(drive[\"uid\"],filename,str(file_path.absolute()),file_path.stat().st_size,file_path.suffix)\n-\n-            await self.services.chat.send(self.request.session.get(\"uid\"),channel_uid,f\"![{filename}](/drive.bin/{drive_item['uid']})\")\n-            print(drive_item,flush=True)\n-\n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files],\"channel_uid\":channel_uid})\n-\n+            await self.services.chat.send(\n+                self.request.session.get(\"uid\"), channel_uid, f\"![{filename}](/drive.bin/{drive_item['uid']})\"\n+            )\n+            print(drive_item, flush=True)\n \n-        \n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 63bacab..e172b8d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,8 +1,33 @@\n-from snek.system.view import BaseView\n \n+\n+\n \n-class WebView(BaseView):\n \n+from snek.system.view import BaseView\n+\n+class WebView(BaseView):\n     login_required = True\n \n     async def get(self):"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added WebView and channel routing\n\nfix: Corrected form validation in LoginView", "commit": "78f9679f308016320b64cd49bb3552fb63d26d27", "diff": "commit 78f9679f308016320b64cd49bb3552fb63d26d27\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 07:54:46 2025 +0100\n\n    Bugfixes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex aaf1029..3020584 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -91,6 +91,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel}.html\", WebView)\n+\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 0c2359d..580655f 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -21,7 +21,7 @@ class LoginView(BaseFormView):\n         return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n-        if await form.is_valid():\n+        if await form.is_valid:\n             user = await self.services.user.get(username=form['username'], deleted_at=None)\n             await self.services.user.save(user)\n             self.session.update({\n@@ -31,4 +31,4 @@ class LoginView(BaseFormView):\n                 \"color\": user[\"color\"]\n             })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex e172b8d..4923183 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -24,11 +24,50 @@\n \n-\n+from aiohttp import web\n from snek.system.view import BaseView\n \n class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n-        return await self.render_template(\"web.html\")\n+\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+\n+        if not self.request.match_info.get(\"channel\"):\n+            channel = await self.app.services.channel.get(\n+                    tag=\"public\",deleted_at=None\n+            )\n+            if not channel:\n+                return web.HTTPNotFound()\n+            return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        else:\n+            print(self.request.match_info.get(\"channel\"), flush=True)\n+            channel = await self.app.services.channel.get(\n+                uid=str(self.request.match_info.get(\"channel\")),deleted_at=None\n+            )\n+            if not channel:\n+\n+                \n+                print(\"TADAAA:\",name, flush=True)\n+\n+                channel = await self.app.services.channel.get(\n+                    label=name,deleted_at=None\n+                )\n+            if not channel:\n+                print(\"NOT found!\\n\",flush=True)\n+                return web.HTTPNotFound()\n+            channel_member = await self.app.services.channel_member.get(\n+                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False\n+            )\n+            if not channel_member:\n+                return web.HTTPNotFound()\n+           \n+        print(\"HIER\\n\",flush=True) \n+        user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        if not user:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user})"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added time descriptions and user switching in chat window", "commit": "e7cd397e0fe98074833e08880d915516718adaf5", "diff": "commit e7cd397e0fe98074833e08880d915516718adaf5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 12:35:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex dcc12d5..309e959 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -22,7 +22,8 @@ class ChannelMessageService(BaseService):\n         context.update(dict(\n             user_uid=user['uid'],\n             username=user['username'],\n-            user_nick=user['nick']\n+            user_nick=user['nick'],\n+            color=user['color']\n         ))\n         try:\n             template = self.app.jinja2_env.get_template(\"message.html\")\n@@ -34,4 +35,27 @@ class ChannelMessageService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n     \n-    \n+    async def to_extended_dict(self, message):\n+        user = await self.services.user.get(uid=message[\"user_uid\"])\n+        if not user:\n+            print(\"User not found!\", flush=True)\n+            return {}\n+        return {\n+            \"uid\": message[\"uid\"],\n+            \"color\": user['color'],\n+            \"user_uid\": message[\"user_uid\"],\n+            \"channel_uid\": message[\"channel_uid\"],\n+            \"user_nick\": user['nick'],\n+            \"message\": message[\"message\"],\n+            \"created_at\": message[\"created_at\"],\n+            \"html\": message['html'],\n+            \"username\": user['username'] \n+        }\n+\n+    async def offset(self, channel_uid, offset=0):\n+        results = []\n+\n+        async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+            results.append(model)\n+        results.sort(key=lambda x: x['created_at'])\n+        return results \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 2f3e610..a0f83c2 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -289,7 +289,7 @@ class App extends EventHandler {\n         this.audio = new NotificationAudio(500);\n         const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid, data);\n+            me.emit(\"channel-message\", data);\n         });\n \n         this.rpc.getUser(null).then(user => {\n@@ -300,7 +300,31 @@ class App extends EventHandler {\n     playSound(index) {\n         this.audio.play(index);\n     }\n-\n+    timeDescription(isoDate) {\n+        const date = new Date(isoDate);\n+        const hours = String(date.getHours()).padStart(2, \"0\");\n+        const minutes = String(date.getMinutes()).padStart(2, \"0\");\n+        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n+        return timeStr;\n+    }\n+    timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1);\n+        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n+        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n+        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n+        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+\n+        if (days) {\n+            return `${days} ${days > 1 ? 'days' : 'day'} ago`;\n+        }\n+        if (hours) {\n+            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;\n+        }\n+        if (minutes) {\n+            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;\n+        }\n+        return 'just now';\n+    }\n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n         const me = this; \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e57b944..e069ff7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -161,7 +161,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n-  \n+  overflow-y: auto;\n   padding: 10px;\n   height: 200px;\n@@ -211,9 +211,20 @@ message-list {\n   word-break: break-word;\n   overflow-wrap: break-word;\n+  display: block;\n \n }\n-\n+.message-content {\n+  width: 100%;\n+    display:block;\n+    p {\n+        display: block;\n+        width:100%;\n+    }\n+}\n+.message-content img {\n+   max-width: 100%; \n+}\n .chat-messages .message .message-content .time {\n   font-size: 0.8em;\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 1e6f9fb..62ed2ee 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -73,7 +73,8 @@ class ChatWindowElement extends HTMLElement {\n         const me = this;\n         channelElement.addEventListener(\"message\", (message) => {\n             if (me.user.uid !== message.detail.user_uid) app.playSound(0);\n-            message.detail.element.scrollIntoView();\n+            \n+            message.detail.element.scrollIntoView({\"block\": \"end\"});\n         });\n     }\n }\ndiff --git a/src/snek/static/push.js b/src/snek/static/push.js\nindex 806a51e..ca6a8a3 100644\n--- a/src/snek/static/push.js\n+++ b/src/snek/static/push.js\n@@ -5,7 +5,7 @@ this.onpush = (event) => {\n         };\n \n         navigator.serviceWorker\n-            .register(\"service-worker.js\")\n+            .register(\"/service-worker.js\")\n             .then((serviceWorkerRegistration) => {\n                 serviceWorkerRegistration.pushManager.subscribe().then(\n                     (pushSubscription) => {\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 6e3052f..c399d2b 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -21,7 +21,7 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n-        formData.append('channel_uid', this.chatInput.channelUid);\n+        formData.append('channel_uid', this.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n@@ -105,7 +105,7 @@ class UploadButtonElement extends HTMLElement {\n             </div>\n         `;\n         this.shadowRoot.appendChild(this.container);\n-\n+        this.channelUid = this.getAttribute('channel');\n         this.uploadButton = this.container.querySelector('.upload-button');\n         this.fileInput = this.container.querySelector('.hidden-input');\n         this.uploadButton.addEventListener('click', () => {\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 89496a0..a543dd7 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -16,6 +16,7 @@ def set_link_target_blank(text):\n         element.attrs['target'] = '_blank'\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n+        element.attrs['href'] = element.attrs['href'].strip(\".\")\n \n     return str(soup)\n     \n@@ -23,7 +24,7 @@ def set_link_target_blank(text):\n def linkify_https(text):\n         return text \n \n     soup = BeautifulSoup(text, 'html.parser')\n \ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 7412c17..07fda6d 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -12,11 +12,6 @@\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n-  <script src=\"/models.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <script src=\"/message-list-manager.js\"></script>\n-  <script src=\"/chat-input.js\"></script>\n-  <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n   <link rel=\"manifest\" href=\"/manifest.json\" />\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex c271db3..8c43c49 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0225965..4078d92 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,4 +1,99 @@\n {% extends \"app.html\" %} \n {% block main %}\n-    <chat-window class=\"chat-area\"></chat-window>\n+    <section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\"><h2>{{ channel.label.value }}</h2></div>\n+    <div class=\"chat-messages\">\n+        \n+        {% for message in messages %}\n+        {% autoescape false %}\n+        {{message.html}}\n+        {% endautoescape %}\n+        <div style=\"display:none\" class=\"message {% if loop.first or message.username != messages[loop.index0 - 1].username %}switch-user{% endif %}\" data-uid=\"{{message.uid}}\" data-color=\"{{message.color}}\" data-user_nick=\"{{message.user_nick}}\" data-created_at=\"{{message.created_at}}\">\n+                <div class=\"avatar no-select\" style=\"background-color: {{message.color}}; color: black;\">{{message.user_nick[0]}}</div> \n+            <div class=\"message-content\">\n+        {% autoescape false %}\n+                {{ message.html }}\n+            \n+        {% endautoescape %}\n+            <div class=\"time no-select\" data-created_at=\"{{message.created_at}}\">{{message.created_at}}</div>\n+            </div>\n+        </div>\n+        {% endfor %}\n+    </div>\n+    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n+    <div class=\"chat-input\">\n+      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+      <upload-button channel=\"{{channel.channel_uid.value}}\"></upload-button>\n+    </div>\n+    </section>\n+    <script>\n+        const channelUid = \"{{channel.channel_uid.value}}\"\n+\n+        function initInputField(textBox){\n+\n+            textBox.addEventListener('change', (e) => {\n+      e.preventDefault();\n+      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+\n+    });\n+\n+    textBox.addEventListener('keydown', (e) => {\n+      if (e.key === 'Enter' && !e.shiftKey) {\n+        e.preventDefault();\n+        const message = e.target.value.trim();\n+        if (!message) return;\n+            app.rpc.sendMessage(channelUid, e.target.value)\n+          e.target.value = '';\n+      }\n+    });\n+        }\n+        initInputField(document.querySelector(\"textarea\"))\n+        function updateTimes(){\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+                time.innerText = app.timeDescription(time.dataset.created_at)\n+        })\n+        }\n+        function updateLayout() {\n+\n+             document.querySelectorAll(\".chat-messages\").forEach((messages) => messages.scrollTop = messages.scrollHeight + 1000)\n+            updateTimes()\n+            let previousUser = null;\n+            document.querySelectorAll(\".message\").forEach((message) => {\n+                if(previousUser != message.dataset.user_uid) {\n+                    message.classList.add(\"switch-user\")\n+                    previousUser = message.dataset.user_uid\n+                }else{\n+                    message.classList.remove(\"switch-user\")\n+                }\n+            })\n+        }\n+         setInterval(() => {\n+                \n+            \n+                updateTimes()\n+\n+            }, 1000)\n+\n+        app.addEventListener(\"channel-message\", (data) => {\n+            if(data.channel_uid != channelUid) return\n+            if(data.username != \"{{user.username.value}}\"){\n+                app.playSound(0)\n+            }\n+            const message = document.createElement(\"div\")\n+            message.dataset.color = data.color\n+            message.dataset.created_at = data.created_at\n+            message.dataset.user_nick = data.user_nick\n+            message.dataset.uid = data.uid\n+            message.innerHTML = data.html\n+            document.querySelector(\".chat-messages\").appendChild(message.firstChild)\n+            setTimeout(()=>{\n+            updateLayout()\n+            },50)\n+        })\n+        updateLayout()\n+    </script>\n {% endblock %}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 53e5aab..9aa89f5 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -73,22 +73,11 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-            async for message in self.services.channel_message.query(\"SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 60\"):\n-                user = await self.services.user.get(uid=message[\"user_uid\"])\n-                if not user:\n-                    print(\"User not found!\", flush=True)\n-                    continue\n-                messages.insert(0, {\n-                    \"uid\": message[\"uid\"],\n-                    \"color\": user['color'],\n-                    \"user_uid\": message[\"user_uid\"],\n-                    \"channel_uid\": message[\"channel_uid\"],\n-                    \"user_nick\": user['nick'],\n-                    \"message\": message[\"message\"],\n-                    \"created_at\": message[\"created_at\"],\n-                    \"html\": message['html'],\n-                    \"username\": user['username'] \n-                })\n+            print(\"Channel uid:\", channel_uid, flush=True)\n+            for message in await self.services.channel_message.offset(channel_uid, offset):\n+                print(message, flush=True)\n+                extended_dict = await self.services.channel_message.to_extended_dict(message)\n+                messages.append(extended_dict)\n             return messages\n \n         async def get_channels(self):\n@@ -167,4 +156,4 @@ class RPCView(BaseView):\n                 pass \n             elif msg.type == web.WSMsgType.CLOSE:\n                 pass \n-        return ws\n\\ No newline at end of file\n+        return ws\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 4923183..e02d8b3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,4 +70,12 @@ class WebView(BaseView):\n         if not user:\n             return web.HTTPNotFound()\n \n-        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user})\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+\n+        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n+            channel[\"uid\"]\n+        )]\n+        \n+\n+        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user,\"messages\": messages})"}
{"repo": ".", "date": "2025-02-09", "line": "style: Added scrollbar styling for chat messages", "commit": "feb5234b3b581936d45ac328b23de7da8f375ee2", "diff": "commit feb5234b3b581936d45ac328b23de7da8f375ee2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 12:44:51 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e069ff7..46517cf 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -336,3 +336,31 @@ input[type=\"text\"] {\n         display: block;\n     }\n }\n+\n+::-webkit-scrollbar {\n+}\n+\n+::-webkit-scrollbar-track {\n+}\n+\n+::-webkit-scrollbar-thumb {\n+}\n+\n+::-webkit-scrollbar-thumb:hover {\n+}\n+\n+.chat-messages{\n+  scrollbar-width: thin;\n+}"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Improved chat message display and user switching", "commit": "ecb77cf361f0d55b512028701c67c1e347836e6e", "diff": "commit ecb77cf361f0d55b512028701c67c1e347836e6e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:06:12 2025 +0100\n\n    So smooth.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4078d92..01c5506 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,99 +1,89 @@\n {% extends \"app.html\" %} \n+\n {% block main %}\n-    <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\"><h2>{{ channel.label.value }}</h2></div>\n+<section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">\n+        <h2>{{ channel.label.value }}</h2>\n+    </div>\n     <div class=\"chat-messages\">\n-        \n         {% for message in messages %}\n-        {% autoescape false %}\n-        {{message.html}}\n-        {% endautoescape %}\n-        <div style=\"display:none\" class=\"message {% if loop.first or message.username != messages[loop.index0 - 1].username %}switch-user{% endif %}\" data-uid=\"{{message.uid}}\" data-color=\"{{message.color}}\" data-user_nick=\"{{message.user_nick}}\" data-created_at=\"{{message.created_at}}\">\n-                <div class=\"avatar no-select\" style=\"background-color: {{message.color}}; color: black;\">{{message.user_nick[0]}}</div> \n-            <div class=\"message-content\">\n-        {% autoescape false %}\n-                {{ message.html }}\n-            \n-        {% endautoescape %}\n-            <div class=\"time no-select\" data-created_at=\"{{message.created_at}}\">{{message.created_at}}</div>\n-            </div>\n-        </div>\n+            {% autoescape false %}\n+            {{ message.html }}\n+            {% endautoescape %}\n         {% endfor %}\n     </div>\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <div class=\"chat-input\">\n-      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-      <upload-button channel=\"{{channel.channel_uid.value}}\"></upload-button>\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <upload-button channel=\"{{ channel.channel_uid.value }}\"></upload-button>\n     </div>\n-    </section>\n-    <script>\n-        const channelUid = \"{{channel.channel_uid.value}}\"\n+</section>\n \n-        function initInputField(textBox){\n+<script>\n+    const channelUid = \"{{ channel.channel_uid.value }}\";\n \n-            textBox.addEventListener('change', (e) => {\n-      e.preventDefault();\n-      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+    function initInputField(textBox) {\n+        textBox.addEventListener('change', (e) => {\n+            e.preventDefault();\n+            this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+        });\n \n-    });\n+        textBox.addEventListener('keydown', (e) => {\n+            if (e.key === 'Enter' && !e.shiftKey) {\n+                e.preventDefault();\n+                const message = e.target.value.trim();\n+                if (message) {\n+                    app.rpc.sendMessage(channelUid, message);\n+                    e.target.value = '';\n+                }\n+            }\n+        });\n+    }\n \n-    textBox.addEventListener('keydown', (e) => {\n-      if (e.key === 'Enter' && !e.shiftKey) {\n-        e.preventDefault();\n-        const message = e.target.value.trim();\n-        if (!message) return;\n-            app.rpc.sendMessage(channelUid, e.target.value)\n-          e.target.value = '';\n-      }\n-    });\n-        }\n-        initInputField(document.querySelector(\"textarea\"))\n-        function updateTimes(){\n+    function updateTimes() {\n         document.querySelectorAll(\".time\").forEach((time) => {\n-                time.innerText = app.timeDescription(time.dataset.created_at)\n-        })\n-        }\n-        function updateLayout() {\n+            time.innerText = app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n \n-             document.querySelectorAll(\".chat-messages\").forEach((messages) => messages.scrollTop = messages.scrollHeight + 1000)\n-            updateTimes()\n-            let previousUser = null;\n-            document.querySelectorAll(\".message\").forEach((message) => {\n-                if(previousUser != message.dataset.user_uid) {\n-                    message.classList.add(\"switch-user\")\n-                    previousUser = message.dataset.user_uid\n-                }else{\n-                    message.classList.remove(\"switch-user\")\n-                }\n-            })\n+    function updateLayout() {\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        messagesContainer.scrollTop = messagesContainer.scrollHeight + 1000;\n+\n+        updateTimes();\n+        let previousUser = null;\n+        document.querySelectorAll(\".message\").forEach((message) => {\n+            if (previousUser !== message.dataset.user_uid) {\n+                message.classList.add(\"switch-user\");\n+                previousUser = message.dataset.user_uid;\n+            } else {\n+                message.classList.remove(\"switch-user\");\n+            }\n+        });\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) return;\n+\n+        if (data.username !== \"{{ user.username.value }}\") {\n+            app.playSound(0);\n         }\n-         setInterval(() => {\n-                \n-            \n-                updateTimes()\n \n-            }, 1000)\n+        const message = document.createElement(\"div\");\n+        message.dataset.color = data.color;\n+        message.dataset.created_at = data.created_at;\n+        message.dataset.user_nick = data.user_nick;\n+        message.dataset.uid = data.uid;\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        updateLayout();\n+    });\n \n-        app.addEventListener(\"channel-message\", (data) => {\n-            if(data.channel_uid != channelUid) return\n-            if(data.username != \"{{user.username.value}}\"){\n-                app.playSound(0)\n-            }\n-            const message = document.createElement(\"div\")\n-            message.dataset.color = data.color\n-            message.dataset.created_at = data.created_at\n-            message.dataset.user_nick = data.user_nick\n-            message.dataset.uid = data.uid\n-            message.innerHTML = data.html\n-            document.querySelector(\".chat-messages\").appendChild(message.firstChild)\n-            setTimeout(()=>{\n-            updateLayout()\n-            },50)\n-        })\n-        updateLayout()\n-    </script>\n+    initInputField(document.querySelector(\"textarea\"));\n+    updateLayout();\n+</script>\n {% endblock %}\n+\n+"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved layout updates after message insertion", "commit": "bfca2bdf734c9b9522186c1ff1b6479f93f34658", "diff": "commit bfca2bdf734c9b9522186c1ff1b6479f93f34658\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:07:38 2025 +0100\n\n    So smooth.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 01c5506..8df9992 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -79,6 +79,9 @@\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout();\n+        setTimeout(()=>{\n+            updateLayout()\n+        },200)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor CSS for improved layout and styling", "commit": "83121f7fa99df690a3b9029556aa023226cf22ef", "diff": "commit 83121f7fa99df690a3b9029556aa023226cf22ef\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:19:54 2025 +0100\n\n    Updated style.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 46517cf..9cbd146 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,6 +1,5 @@\n * {\n   margin: 0;\n   box-sizing: border-box;\n }\n \n@@ -9,23 +8,14 @@\n   height: auto;\n   overflow: auto;\n   flex: 1;\n-  &.tile {\n+}\n+\n+.gallery.tile, .tile {\n   width: 100px;\n   height: 100px;\n   object-fit: cover;\n-  margin-right: 10px;\n+  margin: 20px 10px 20px 0;\n   border-radius: 5px;\n-  margin: 20px;\n-  }\n-}\n-.tile {\n-  \n-    width: 100px;\n-    height: 100px;\n-    object-fit: cover;\n-    margin-right: 10px;\n-    border-radius: 5px;\n-    margin: 20px;\n }\n \n body {\n@@ -38,8 +28,11 @@ body {\n   height: 100vh;\n   min-width: 100%;\n }\n+\n main {\n-  min-width: 100%;\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n }\n \n header {\n@@ -63,12 +56,13 @@ header nav a {\n   font-size: 1em;\n   transition: color 0.3s;\n }\n+\n .no-select {\n-    }\n+  -webkit-user-select: none;  \n+  -moz-user-select: none;     \n+  -ms-user-select: none;      \n+  user-select: none;          \n+}\n \n header nav a:hover {\n@@ -80,62 +74,6 @@ a {\n   margin-bottom: 3px;\n }\n \n-.chat-area ul {\n-    margin: 0px;\n-    padding: 0px;\n-li {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n-    list-style: none;\n-      padding: 0;\n-      margin: 0; \n-      font-size: 1.5em;\n-    a {\n-        text-decoration: none;\n-    }\n-  \n-}\n-}\n-main {\n-  display: flex;\n-  flex: 1;\n-  overflow: hidden;\n-}\n-\n-.sidebar {\n-  width: 250px;\n-  padding: 20px;\n-  overflow-y: auto;\n-}\n-\n-.sidebar h2 {\n-  font-size: 1.2em;\n-  margin-bottom: 20px;\n-}\n-\n-.sidebar ul {\n-  list-style: none;\n-}\n-\n-.sidebar ul li {\n-  margin-bottom: 15px;\n-}\n-\n-.sidebar ul li a {\n-  text-decoration: none;\n-  font-size: 1em;\n-  transition: color 0.3s;\n-}\n-\n-.sidebar ul li a:hover {\n-}\n-\n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -153,12 +91,14 @@ main {\n   font-size: 1.2em;\n }\n-message-list {\n-  flex: 1;;\n-   height: 200px;\n-   padding-bottom: 40px;\n+\n+.message-list {\n+  flex: 1;\n+  height: 200px;\n+  padding-bottom: 40px;\n   overflow-y: auto;\n }\n+\n .chat-messages {\n   flex: 1;\n   overflow-y: auto;\n@@ -167,20 +107,12 @@ message-list {\n }\n \n-.message-list-manager {\n-  flex: 1;\n-  overflow-y: auto;\n-}\n-\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\n-  margin-bottom: 0px;\n+  margin-bottom: 0;\n   padding: 5px;\n-  \n   border-radius: 8px;\n }\n \n .chat-messages .message .avatar {\n@@ -196,6 +128,7 @@ message-list {\n   align-items: center;\n   margin-right: 15px;\n }\n+\n .chat-messages .message .message-content {\n   flex: 1;\n }\n@@ -211,20 +144,17 @@ message-list {\n   word-break: break-word;\n   overflow-wrap: break-word;\n-  display: block;\n-\n }\n+\n .message-content {\n   width: 100%;\n-    display:block;\n-    p {\n-        display: block;\n-        width:100%;\n-    }\n+  display: block;\n }\n+\n .message-content img {\n-   max-width: 100%; \n+  max-width: 100%; \n }\n+\n .chat-messages .message .message-content .time {\n   font-size: 0.8em;\n@@ -238,17 +168,7 @@ message-list {\n }\n \n-input[type=\"text\"] {\n-  flex: 1;\n-  color: white;\n-  border: none;\n-  padding: 10px;\n-  border-radius: 5px;\n-  resize: none;\n-}\n-\n-.chat-input textarea {\n+input[type=\"text\"], .chat-input textarea {\n   flex: 1;\n   color: white;\n@@ -278,89 +198,101 @@ input[type=\"text\"] {\n   .sidebar {\n     display: none;\n   }\n-\n-  .chat-area {\n-    flex: 1;\n-  }\n }\n \n .message {\n   .text {\n-  max-width: 100%;\n-  word-wrap: break-word;\n-  overflow-wrap: break-word;\n-  hyphens: auto;\n-    img {\n-      max-width: 90%;\n-      border-radius: 20px;\n-    }\n-    {\n-        padding: 0;\n-        margin: 0;\n-    }\n-  }\n-  .avatar {\n-      opacity: 0;\n+    max-width: 100%;\n+    word-wrap: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n   }\n \n-  .author {\n-      display: none;\n+  .avatar {\n+    opacity: 0;\n   }\n \n-  .time {\n-      display: none;\n+  .author, .time {\n+    display: none;\n   }\n }\n+\n .message.switch-user {\n-  .text {\n-  max-width: 100%;\n-  word-wrap: break-word;\n-  overflow-wrap: break-word;\n-  hyphens: auto;\n-    img{\n-      max-width: 90%;\n-      border-radius: 20px;\n-    }\n+  .text img {\n+    max-width: 90%;\n+    border-radius: 20px;\n   }\n+  \n   .avatar {\n-      opacity: 1;\n+    opacity: 1;\n   }\n+\n   .author {\n-      display: block;\n+    display: block;\n   }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child\n- {\n-    .time {\n-        display: block;\n-    }\n+.message:has(+ .message.switch-user), .message:last-child .time {\n+  display: block;\n }\n \n ::-webkit-scrollbar {\n+  width: 6px;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n+  border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n-.chat-messages{\n+.chat-messages {\n   scrollbar-width: thin;\n }\n+\n+a {\n+    text-decoration:none\n+}\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Correctly append message element to chat messages", "commit": "dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797", "diff": "commit dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 14:58:26 2025 +0100\n\n    Updated web.html.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df9992..38d652e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -77,7 +77,7 @@\n         message.dataset.user_nick = data.user_nick;\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        document.querySelector(\".chat-messages\").appendChild(message);\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improve text wrapping and hyphenation for better readability", "commit": "cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd", "diff": "commit cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:46:02 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9cbd146..8389618 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -138,12 +138,24 @@ a {\n   margin-bottom: 3px;\n }\n-\n+* {\n+word-break: break-word;\n+  overflow-wrap: break-word;\n+    hyphens: auto;\n+}\n+.highlight pre {\n+    white-space: pre-wrap;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n+  }\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n   word-break: break-word;\n   overflow-wrap: break-word;\n+hyphens: auto;\n+ \n }\n \n .message-content {\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 8c43c49..2773f0a 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message appending to chat messages", "commit": "a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29", "diff": "commit a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:53:45 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 38d652e..8df9992 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -77,7 +77,7 @@\n         message.dataset.user_nick = data.user_nick;\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message);\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved message content styling and hyphenation", "commit": "661eba7161c1d869d73f04641878521fbdf8b72a", "diff": "commit 661eba7161c1d869d73f04641878521fbdf8b72a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:56:19 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 8389618..1aa4ba5 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -154,13 +154,12 @@ word-break: break-word;\n   word-break: break-word;\n   overflow-wrap: break-word;\n-hyphens: auto;\n- \n+hyphens: auto; \n }\n \n .message-content {\n-  width: 100%;\n   display: block;\n+    max-width: 100%;\n }\n \n .message-content img {"}
{"repo": ".", "date": "2025-02-09", "line": "style: Removed unnecessary block display from message content.", "commit": "e75836fe879e4f7e2a8bb34ed8ca901cc624ce05", "diff": "commit e75836fe879e4f7e2a8bb34ed8ca901cc624ce05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:59:26 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 1aa4ba5..83378a0 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -158,7 +158,6 @@ hyphens: auto;\n }\n \n .message-content {\n-  display: block;\n     max-width: 100%;\n }"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message time display", "commit": "0f400a0b6aa04ffdfd8de1e26be3318a39a174a8", "diff": "commit 0f400a0b6aa04ffdfd8de1e26be3318a39a174a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 16:14:00 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 83378a0..7483432 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -242,9 +242,11 @@ input[type=\"text\"], .chat-input textarea {\n   }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child .time {\n+.message:has(+ .message.switch-user), .message:last-child{ \n+    .time {\n   display: block;\n }\n+}\n \n ::-webkit-scrollbar {"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Enable CORS credentials", "commit": "49c0f932ab3e5705380a57cefa8da9ea7b9967d3", "diff": "commit 49c0f932ab3e5705380a57cefa8da9ea7b9967d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 23:49:54 2025 +0100\n\n    Updated cors.\n\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 69fe378..2946262 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -22,6 +22,7 @@ async def cors_allow_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response\n \n \n@@ -34,10 +35,12 @@ async def cors_middleware(request, handler):\n             \"GET, POST, PUT, DELETE, OPTIONS\"\n         )\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+        response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n         return response\n \n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implemented online status and ping functionality", "commit": "54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1", "diff": "commit 54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:18:08 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex cac9aaf..54b6f30 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,3 +29,5 @@ class UserModel(BaseModel):\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n     password = ModelField(name=\"password\", required=True, min_length=1)\n+\n+    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex ee23c2d..357c5dd 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,5 +1,7 @@\n from snek.system.service import BaseService\n+from datetime import datetime \n \n+from snek.system.model import now \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n@@ -29,6 +31,25 @@ class ChannelService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n+    async def get_users(self, channel_uid):\n+        users = []\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_uid,\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            yield await self.services.user.get(uid=channel_member[\"user_uid\"])\n+\n+    async def get_online_users(self, channel_uid):\n+        users = []\n+        async for user in self.get_users(channel_uid):\n+            if not user[\"last_ping\"]:\n+                continue\n+\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 30:\n+                yield user \n+\n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\n         is_moderator = False\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 1cad3ee..02707b0 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -27,6 +27,7 @@ class UserService(BaseService):\n             user['color'] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a0f83c2..b6b468b 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -206,11 +206,14 @@ class Socket extends EventHandler {\n                 ws.onerror = () => {\n                     this.onClose();\n                 };\n+                this.onConnect()\n                 this.connectPromises.forEach(resolver => resolver(this));\n             };\n         });\n     }\n-\n+    onConnect(){\n+        this.emit(\"connected\")\n+    }\n     onData(data) {\n         if (data.success !== undefined && !data.success) {\n             console.error(data);\n@@ -282,12 +285,31 @@ class App extends EventHandler {\n     audio = null;\n     user = {};\n \n+    async ping(...args) {\n+        if(this.is_pinging)return false \n+        this.is_pinging = true\n+        await this.rpc.ping(...args);\n+        this.is_pinging = false\n+    }\n+    async forcePing(...arg) {\n+        await this.rpc.ping(...args);\n+    }\n+\n     constructor() {\n         super();\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n+        this.is_pinging = false \n+        this.ping_interval = setInterval(()=>{\n+            this.ping(\"active\")\n+        }, 15000)\n+        \n+\n         const me = this \n+        this.ws.addEventListener(\"connected\", (data)=> {\n+            this.ping(\"online\")\n+        })\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(\"channel-message\", data);\n         });\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 9aa89f5..02e7bff 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -11,6 +11,7 @@ from aiohttp import web\n from snek.system.view import BaseView\n import traceback\n import json\n+from snek.system.model import now \n \n class RPCView(BaseView):\n \n@@ -133,8 +134,24 @@ class RPCView(BaseView):\n \n         async def _send_json(self, obj):\n             await self.ws.send_str(json.dumps(obj, default=str))\n+    \n \n-        async def call_ping(self, callId, *args):\n+        async def get_online_users(self, channel_uid):\n+            self._require_login()\n+            \n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_online_users(channel_uid)]\n+\n+\n+        async def get_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_users(channel_uid)]\n+\n+        async def ping(self, callId, *args):\n+            if self.user_uid:\n+                user = await self.services.user.get(uid=self.user_uid)\n+                user['last_ping'] = now()\n+                await self.services.user.save(user)\n             return {\"pong\": args}\n \n     async def get(self):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Display online status for channel members", "commit": "688e7fbf0e8977f442edc41cea1ac2a06f1ece40", "diff": "commit 688e7fbf0e8977f442edc41cea1ac2a06f1ece40\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:22:34 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 357c5dd..72d9798 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -39,8 +39,9 @@ class ChannelService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            yield await self.services.user.get(uid=channel_member[\"user_uid\"])\n-\n+            user =  await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if user:\n+                yield user\n     async def get_online_users(self, channel_uid):\n         users = []\n         async for user in self.get_users(channel_uid):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Adjust online status check threshold", "commit": "48891c438694d37cd1b8338a2cc1f96f7647e77d", "diff": "commit 48891c438694d37cd1b8338a2cc1f96f7647e77d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:24:43 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 72d9798..567eba6 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -48,7 +48,7 @@ class ChannelService(BaseService):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 30:\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() >= 20:\n                 yield user \n \n     async def ensure_public_channel(self, created_by_uid):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Include last ping in online user data", "commit": "087ab1a8a55ae58b52078dc5cb7de7db65132e84", "diff": "commit 087ab1a8a55ae58b52078dc5cb7de7db65132e84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:32:30 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 02e7bff..deb4286 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -139,13 +139,13 @@ class RPCView(BaseView):\n         async def get_online_users(self, channel_uid):\n             self._require_login()\n             \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_online_users(channel_uid)]\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n \n \n         async def get_users(self, channel_uid):\n             self._require_login()\n \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_users(channel_uid)]\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_users(channel_uid)]\n \n         async def ping(self, callId, *args):\n             if self.user_uid:"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implement online user status indicator", "commit": "3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493", "diff": "commit 3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:34:26 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 567eba6..e169d7c 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -48,7 +48,7 @@ class ChannelService(BaseService):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() >= 20:\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n                 yield user \n \n     async def ensure_public_channel(self, created_by_uid):"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added private chat functionality with DM creation", "commit": "8a59ddd210bb3ab3d29f9207afbf887988b528d9", "diff": "commit 8a59ddd210bb3ab3d29f9207afbf887988b528d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:34:31 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex e169d7c..5291189 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -6,6 +6,27 @@ from snek.system.model import now\n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n+    async def get(\n+        self,\n+        uid=None,\n+        **kwargs):\n+        if uid:\n+            kwargs['uid'] = uid \n+            result = await super().get(**kwargs)\n+            if result:\n+                return result \n+            del kwargs['uid']\n+            kwargs['name'] = uid \n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            return None \n+        return await super().get(**kwargs)\n+\n     async def create(\n         self,\n         label,\n@@ -31,6 +52,20 @@ class ChannelService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n+    async def get_dm(self, user1, user2):\n+        channel_member = await self.services.channel_member.get_dm(\n+            user1, user2 \n+        )\n+        if channel_member:\n+            return await self.get(uid=channel_member[\"channel_uid\"])\n+        channel = await self.create(\n+            \"DM\", user1, tag=\"DM\"            \n+        )\n+        await self.services.channel_member.create_dm(\n+            channel[\"uid\"], user1, user2            \n+        )\n+        return channel \n+\n     async def get_users(self, channel_uid):\n         users = []\n         async for channel_member in self.services.channel_member.find(\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 6b9ce9e..f3ced31 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -16,7 +16,7 @@ class ChannelMemberService(BaseService):\n     ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n-            if model.is_banned.value:\n+            if model['is_banned']:\n                 return False\n             return model\n         model = await self.new()\n@@ -32,3 +32,17 @@ class ChannelMemberService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n+    \n+    async def get_dm(self,from_user, to_user):\n+        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+            return model\n+        return None \n+\n+    async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n+        result = await self.create(channel_uid, from_user_uid,tag=\"dm\")\n+        await self.create(channel_uid, to_user_uid,tag=\"dm\")\n+        return result \n+\n+\n+\n+\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex a088854..d65f947 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -40,7 +40,7 @@ class BaseService:\n         if uid:\n             if not kwargs:\n                 result = await self.cache.get(uid)\n-                if result:\n+                if False and result and result.__class__ == self.mapper.model_class:\n                     return result\n             kwargs[\"uid\"] = uid\n \ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex ba5b51b..8bce656 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -14,7 +14,7 @@\n         <ul>\n         {% for user in users %}\n     <li>\n-        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+        <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n     </li>\n     \n     {% endfor %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df9992..51f1117 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -15,12 +15,12 @@\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-        <upload-button channel=\"{{ channel.channel_uid.value }}\"></upload-button>\n+        <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n \n <script>\n-    const channelUid = \"{{ channel.channel_uid.value }}\";\n+    const channelUid = \"{{ channel.uid.value }}\";\n \n     function initInputField(textBox) {\n         textBox.addEventListener('change', (e) => {\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex e02d8b3..7eb4a9a 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -31,22 +31,44 @@ class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        channel = await self.services.channel.get(uid=self.request.match_info.get(\"channel\"))\n+        if not channel:\n+            user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+            if user:\n+                channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+        if not channel:\n+            return web.HTTPNotFound()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n+            channel[\"uid\"]\n+        )]\n+        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages})\n+\n+        \n+\n+\n+    async def get2(self):\n \n         if self.login_required and not self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/\")\n \n+        channel = None\n+\n         if not self.request.match_info.get(\"channel\"):\n             channel = await self.app.services.channel.get(\n                     tag=\"public\",deleted_at=None\n             )\n-            if not channel:\n-                return web.HTTPNotFound()\n-            return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        else:\n+            if channel:\n+                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        if not channel:\n             print(self.request.match_info.get(\"channel\"), flush=True)\n             channel = await self.app.services.channel.get(\n-                uid=str(self.request.match_info.get(\"channel\")),deleted_at=None\n+                uid=str(self.request.match_info.get(\"channel\"))\n             )\n+            if channel:\n+                print(f'found {channel[\"uid\"]} {channel[\"name\"]}', flush=True)\n             if not channel:\n \n@@ -56,16 +78,77 @@ class WebView(BaseView):\n                 channel = await self.app.services.channel.get(\n                     label=name,deleted_at=None\n                 )\n+            if not channel:\n+                user = await self.app.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+                if not user:\n+                    print(\"HIERRR EXIT\\n\",flush=True)\n+                    return web.HTTPNotFound()\n+                \n+                print(\"FOUND USer: \",user['username'],flush=True)\n+                own_channels = self.app.services.channel_member.find(\n+                    user_uid=self.session.get(\"uid\"),deleted_at=None\n+                )\n+                user_channels = self.app.services.channel_member.find(\n+                    user_uid=user['uid'],deleted_at=None\n+                )\n+                found_channel = False\n+                async for user_channel in user_channels:\n+                    if found_channel:\n+                        break\n+                    async for own_channel in own_channels:\n+                        if user_channel[\"channel_uid\"] == own_channel[\"channel_uid\"]:\n+                            channel = await self.app.services.channel.get(uid=user_channel[\"channel_uid\"])\n+                            if channel['tag'] == 'dm':\n+                                found_channel = True \n+                                print(\"FOUND DM\\n\",flush=True)\n+                                break\n+                            channel = None \n+                        else:\n+                            print(\"Channel mistmatch!\\n\",flush=True)\n+                if found_channel:\n+                    print(f\"FOUND CHANNEL; {channel['uid']}\\n\",flush=True)\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+ \n+                channel = await self.app.services.channel.create(\n+                    label=\"Direct Message\",\n+                    created_by_uid=self.session.get(\"uid\"),\n+                    tag=\"dm\",\n+                    description=\"Direct Chat\",\n+                    is_private=True,\n+                    is_listed=True\n+                )\n+                print(f\"UID NEW CHANNELr: {channel['uid']}\\n\",flush=True)\n+                channel_member_self = await self.app.services.channel_member.create(\n+                    channel_uid=channel['uid'],\n+                    user_uid=self.session.get(\"uid\"),\n+                    is_moderator=True\n+                )\n+                print(f\"UID NEW CHANNEL_MEMBER SELF: {channel_member_self['uid']}\\n\",flush=True)\n+                channel_member_user = await self.app.services.channel_member.create(\n+                    channel_uid=channel['uid'],\n+                    user_uid=user[\"uid\"],\n+                    is_moderator=True\n+                )     \n+                print(f\"UID NEW CHANNEL_MEMBER USER: {channel_member_user['uid']}\\n\",flush=True)\n+                self.app.db.commit()\n+                print(f\"REDIRECT NAAR GOEDE: {channel['uid']}\\n\",flush=True)\n+                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+            \n             if not channel:\n                 print(\"NOT found!\\n\",flush=True)\n                 return web.HTTPNotFound()\n+            print(channel['uid'],\":\",self.session.get('uid'),flush=True)\n+            from pprint import pprint as pp \n+            pp(channel)\n             channel_member = await self.app.services.channel_member.get(\n-                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False\n+                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None\n             )\n             if not channel_member:\n+                print(\"NO CHANNEL_MEMBER\")\n                 return web.HTTPNotFound()\n            \n-        print(\"HIER\\n\",flush=True) \n+        print(\"HIER\\n\",flush=True)\n+        print(\"UUID=\",self.session.get(\"uid\"),flush=True)\n         user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n         if not user:\n             return web.HTTPNotFound()"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality", "commit": "ca463b79a88687a76d9fab851b8f2ffc7e071e81", "diff": "commit ca463b79a88687a76d9fab851b8f2ffc7e071e81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:38:05 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex f3ced31..075e49e 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -39,8 +39,8 @@ class ChannelMemberService(BaseService):\n         return None \n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n-        result = await self.create(channel_uid, from_user_uid,tag=\"dm\")\n-        await self.create(channel_uid, to_user_uid,tag=\"dm\")\n+        result = await self.create(channel_uid, from_user_uid)\n+        await self.create(channel_uid, to_user_uid)\n         return result"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and updated tag to lowercase", "commit": "8fe24f711cb3e796471145a086c4b72289e12e1a", "diff": "commit 8fe24f711cb3e796471145a086c4b72289e12e1a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:40:04 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 5291189..a1ff9e6 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -59,7 +59,7 @@ class ChannelService(BaseService):\n         if channel_member:\n             return await self.get(uid=channel_member[\"channel_uid\"])\n         channel = await self.create(\n-            \"DM\", user1, tag=\"DM\"            \n+            \"DM\", user1, tag=\"dm\"            \n         )\n         await self.services.channel_member.create_dm(\n             channel[\"uid\"], user1, user2"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat redirection", "commit": "bfe4b351c1fa750c9d12d3ae880928cca0346bba", "diff": "commit bfe4b351c1fa750c9d12d3ae880928cca0346bba\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:42:03 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 7eb4a9a..c1710d5 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -38,6 +38,8 @@ class WebView(BaseView):\n             user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n             if user:\n                 channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+                if channel:\n+                    return await web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "be35a6caf07c51eaf79625ad914215bebb9b11c5", "diff": "commit be35a6caf07c51eaf79625ad914215bebb9b11c5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:42:54 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex c1710d5..8643b6d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -39,7 +39,7 @@ class WebView(BaseView):\n             if user:\n                 channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n                 if channel:\n-                    return await web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added base URL property and file type handling for uploads", "commit": "2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420", "diff": "commit 2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 19:30:55 2025 +0100\n\n    Iframe funcs.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8c7537e..5371d33 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -12,6 +12,10 @@ class BaseView(web.View):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \n+    @property \n+    def base_url(self):\n+        return str(self.request.url.with_path('').with_query(''))\n+\n     @property\n     def app(self):\n         return self.request.app\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 2d7d98b..e8abc32 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -38,6 +38,17 @@ class UploadView(BaseView):\n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n         print(str(drive), flush=True)\n+        extension_types = {\n+            \".jpg\": \"image\",\n+            \".gif\": \"image\",\n+            \".png\": \"image\",\n+            \".jpeg\": \"image\",\n+            \".mp4\": \"video\",\n+            \".mp3\": \"audio\",\n+            \".pdf\": \"document\",\n+            \".doc\": \"document\",\n+            \".docx\": \"document\"\n+        }\n \n         while field := await reader.next():\n             if field.name == \"channel_uid\":\n@@ -58,10 +69,20 @@ class UploadView(BaseView):\n             drive_item = await self.services.drive_item.create(\n                 drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n             )\n+           \n+            type_ = \"unknown\"\n+            extension = \".\" + filename.split(\".\")[-1]\n+            if extension in extension_types:\n+                type_ = extension_types[extension] \n+            \n+            await self.services.drive_item.save(drive_item)\n+            response = \"<iframe width=\\\"100%\\\" frameborder=\\\"0\\\" allowfullscreen title=\\\"Embedded\\\" src=\\\"\" + self.base_url + \"/drive.bin/\" + drive_item[\"uid\"] + \"\\\"></iframe>\\n\"\n+            if type_ == \"image\":\n+                response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n \n             await self.services.chat.send(\n-                self.request.session.get(\"uid\"), channel_uid, f\"![{filename}](/drive.bin/{drive_item['uid']})\"\n+                self.request.session.get(\"uid\"), channel_uid, response\n             )\n             print(drive_item, flush=True)\n \n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n\\ No newline at end of file\n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Display uploaded files as links instead of iframes", "commit": "2541fc536aa45630ec55297c58787686e4154fab", "diff": "commit 2541fc536aa45630ec55297c58787686e4154fab\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 19:34:47 2025 +0100\n\n    Iframe funcs.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex e8abc32..8f13420 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -76,7 +76,8 @@ class UploadView(BaseView):\n                 type_ = extension_types[extension] \n             \n             await self.services.drive_item.save(drive_item)\n-            response = \"<iframe width=\\\"100%\\\" frameborder=\\\"0\\\" allowfullscreen title=\\\"Embedded\\\" src=\\\"\" + self.base_url + \"/drive.bin/\" + drive_item[\"uid\"] + \"\\\"></iframe>\\n\"\n+            response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n             if type_ == \"image\":\n                 response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\""}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added echo endpoint and noresponse return value", "commit": "b6eba608435be4d798e50328f9149a8768a5cc8e", "diff": "commit b6eba608435be4d798e50328f9149a8768a5cc8e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 21:11:21 2025 +0100\n\n    Echo service.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex deb4286..b164c2c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -127,8 +127,9 @@ class RPCView(BaseView):\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n-                    success = False \n-                await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n+                    success = False\n+                if result != \"noresponse\":\n+                    await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n@@ -141,6 +142,9 @@ class RPCView(BaseView):\n             \n             return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n \n+        async def echo(self, obj):\n+            await self.ws.send_json(obj)\n+            return \"noresponse\"\n \n         async def get_users(self, channel_uid):\n             self._require_login()"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list to app.html and web.py", "commit": "3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0", "diff": "commit 3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 13 19:47:05 2025 +0100\n\n    Added channel list.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex a1ff9e6..95ceef7 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -86,6 +86,15 @@ class ChannelService(BaseService):\n             if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n                 yield user \n \n+    async def get_for_user(self, user_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            user_uid=user_uid,\n+            is_banned=False,\n+            deleted_at=None,\n+        ):\n+            channel = await self.get(uid=channel_member[\"channel_uid\"])\n+            yield channel \n+\n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\n         is_moderator = False\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 07fda6d..c50ff60 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -33,11 +33,9 @@\n     <aside class=\"sidebar\">\n       <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n-       \n+        {% for channel in channels %}\n+        <li><a class=\"no-select\" href=\"/web/{{channel['channel_uid']}}.html\">{{channel['label']}}</a></li>\n+        {% endfor %}\n       </ul>\n     </aside>\n     {% endblock %}\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 8643b6d..412ba7d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,121 +46,8 @@ class WebView(BaseView):\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n         )]\n-        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages})\n-\n-        \n-\n-\n-    async def get2(self):\n-\n-        if self.login_required and not self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/\")\n-\n-        channel = None\n-\n-        if not self.request.match_info.get(\"channel\"):\n-            channel = await self.app.services.channel.get(\n-                    tag=\"public\",deleted_at=None\n-            )\n-            if channel:\n-                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        if not channel:\n-            print(self.request.match_info.get(\"channel\"), flush=True)\n-            channel = await self.app.services.channel.get(\n-                uid=str(self.request.match_info.get(\"channel\"))\n-            )\n-            if channel:\n-                print(f'found {channel[\"uid\"]} {channel[\"name\"]}', flush=True)\n-            if not channel:\n-\n-                \n-                print(\"TADAAA:\",name, flush=True)\n-\n-                channel = await self.app.services.channel.get(\n-                    label=name,deleted_at=None\n-                )\n-            if not channel:\n-                user = await self.app.services.user.get(uid=self.request.match_info.get(\"channel\"))\n-                if not user:\n-                    print(\"HIERRR EXIT\\n\",flush=True)\n-                    return web.HTTPNotFound()\n-                \n-                print(\"FOUND USer: \",user['username'],flush=True)\n-                own_channels = self.app.services.channel_member.find(\n-                    user_uid=self.session.get(\"uid\"),deleted_at=None\n-                )\n-                user_channels = self.app.services.channel_member.find(\n-                    user_uid=user['uid'],deleted_at=None\n-                )\n-                found_channel = False\n-                async for user_channel in user_channels:\n-                    if found_channel:\n-                        break\n-                    async for own_channel in own_channels:\n-                        if user_channel[\"channel_uid\"] == own_channel[\"channel_uid\"]:\n-                            channel = await self.app.services.channel.get(uid=user_channel[\"channel_uid\"])\n-                            if channel['tag'] == 'dm':\n-                                found_channel = True \n-                                print(\"FOUND DM\\n\",flush=True)\n-                                break\n-                            channel = None \n-                        else:\n-                            print(\"Channel mistmatch!\\n\",flush=True)\n-                if found_channel:\n-                    print(f\"FOUND CHANNEL; {channel['uid']}\\n\",flush=True)\n-                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n- \n-                channel = await self.app.services.channel.create(\n-                    label=\"Direct Message\",\n-                    created_by_uid=self.session.get(\"uid\"),\n-                    tag=\"dm\",\n-                    description=\"Direct Chat\",\n-                    is_private=True,\n-                    is_listed=True\n-                )\n-                print(f\"UID NEW CHANNELr: {channel['uid']}\\n\",flush=True)\n-                channel_member_self = await self.app.services.channel_member.create(\n-                    channel_uid=channel['uid'],\n-                    user_uid=self.session.get(\"uid\"),\n-                    is_moderator=True\n-                )\n-                print(f\"UID NEW CHANNEL_MEMBER SELF: {channel_member_self['uid']}\\n\",flush=True)\n-                channel_member_user = await self.app.services.channel_member.create(\n-                    channel_uid=channel['uid'],\n-                    user_uid=user[\"uid\"],\n-                    is_moderator=True\n-                )     \n-                print(f\"UID NEW CHANNEL_MEMBER USER: {channel_member_user['uid']}\\n\",flush=True)\n-                self.app.db.commit()\n-                print(f\"REDIRECT NAAR GOEDE: {channel['uid']}\\n\",flush=True)\n-                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-            \n-            if not channel:\n-                print(\"NOT found!\\n\",flush=True)\n-                return web.HTTPNotFound()\n-            print(channel['uid'],\":\",self.session.get('uid'),flush=True)\n-            from pprint import pprint as pp \n-            pp(channel)\n-            channel_member = await self.app.services.channel_member.get(\n-                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None\n-            )\n-            if not channel_member:\n-                print(\"NO CHANNEL_MEMBER\")\n-                return web.HTTPNotFound()\n-           \n-        print(\"HIER\\n\",flush=True)\n-        print(\"UUID=\",self.session.get(\"uid\"),flush=True)\n-        user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        if not user:\n-            return web.HTTPNotFound()\n-\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-\n-        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n-            channel[\"uid\"]\n-        )]\n-        \n-\n-        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user,\"messages\": messages})\n+        channels = []\n+        async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+            print(\"CHANNELL!!\\n\",flush=True)\n+            channels.append(subscribed_channel)\n+        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and improved DM user display\n\nfix: Resolved an issue with message scrolling and updated channel links", "commit": "37da903936e4ab85fae254421c356966991d53e4", "diff": "commit 37da903936e4ab85fae254421c356966991d53e4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 13 20:21:34 2025 +0100\n\n    Added channel list.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 075e49e..407d0b7 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -37,6 +37,19 @@ class ChannelMemberService(BaseService):\n         async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n             return model\n         return None \n+    \n+    async def get_other_dm_user(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n+        if channel[\"tag\"] != \"dm\":\n+            print(\"NONT!\\n\", flush=True)\n+            return None\n+        print(\"YEAHH\",flush=True)\n+        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n+            print(\"huh!!!\",model['uid'],flush=True)\n+            if model[\"uid\"] != channel_member['uid']:\n+                print(\"GOOOD!!\",flush=True)\n+                return await self.services.user.get(uid=model[\"user_uid\"])\n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n         result = await self.create(channel_uid, from_user_uid)\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 309e959..7607f03 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -54,8 +54,10 @@ class ChannelMessageService(BaseService):\n \n     async def offset(self, channel_uid, offset=0):\n         results = []\n-\n-        async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n-            results.append(model)\n+        try:\n+            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+                results.append(model)\n+        except: \n+            pass\n         results.sort(key=lambda x: x['created_at'])\n         return results \ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex c50ff60..8caaef8 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -34,7 +34,7 @@\n       <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n         {% for channel in channels %}\n-        <li><a class=\"no-select\" href=\"/web/{{channel['channel_uid']}}.html\">{{channel['label']}}</a></li>\n+        <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\n         {% endfor %}\n       </ul>\n     </aside>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 51f1117..1430a04 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -48,8 +48,8 @@\n \n     function updateLayout() {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        messagesContainer.scrollTop = messagesContainer.scrollHeight + 1000;\n-\n+        \n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -60,6 +60,9 @@\n                 message.classList.remove(\"switch-user\");\n             }\n         });\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        lastMessage.scrollIntoView({ inline: \"nearest\" });\n+\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -81,7 +84,7 @@\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()\n-        },200)\n+        },1000)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 412ba7d..dda589f 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -48,6 +48,13 @@ class WebView(BaseView):\n         )]\n         channels = []\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-            print(\"CHANNELL!!\\n\",flush=True)\n-            channels.append(subscribed_channel)\n+            item = {}\n+            other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n+            if other_user:\n+                item[\"name\"] = other_user[\"nick\"]\n+                item[\"uid\"] = other_user[\"uid\"]\n+            else:\n+                item[\"name\"] = subscribed_channel[\"label\"]\n+                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+            channels.append(item)\n         return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Refactor socket communication and remove unnecessary prints.\n\nfix: Corrected pagination offset in channel message retrieval.", "commit": "1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb", "diff": "commit 1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 21:07:02 2025 +0100\n\n    Update socket communicaton and removed prints.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 407d0b7..f34f08b 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,7 +28,6 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n-        print(model.record, flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n@@ -42,13 +41,9 @@ class ChannelMemberService(BaseService):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n         if channel[\"tag\"] != \"dm\":\n-            print(\"NONT!\\n\", flush=True)\n             return None\n-        print(\"YEAHH\",flush=True)\n         async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            print(\"huh!!!\",model['uid'],flush=True)\n             if model[\"uid\"] != channel_member['uid']:\n-                print(\"GOOOD!!\",flush=True)\n                 return await self.services.user.get(uid=model[\"user_uid\"])\n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 7607f03..c421b5e 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -38,7 +38,6 @@ class ChannelMessageService(BaseService):\n     async def to_extended_dict(self, message):\n         user = await self.services.user.get(uid=message[\"user_uid\"])\n         if not user:\n-            print(\"User not found!\", flush=True)\n             return {}\n         return {\n             \"uid\": message[\"uid\"],\n@@ -52,10 +51,11 @@ class ChannelMessageService(BaseService):\n             \"username\": user['username'] \n         }\n \n-    async def offset(self, channel_uid, offset=0):\n+    async def offset(self, channel_uid, page=0, page_size=30):\n         results = []\n+        offset = page * page_size \n         try:\n-            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n                 results.append(model)\n         except: \n             pass\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex b058044..a0f1e9b 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,4 +1,4 @@\n-\n+from snek.model.user import UserModel\n \n \n from snek.system.service import BaseService\n@@ -6,35 +6,58 @@ from snek.system.service import BaseService\n \n class SocketService(BaseService):\n \n+    class Socket:\n+        def __init__(self, ws, user: UserModel):\n+            self.ws = ws\n+            self.is_connected = True \n+            self.user = user \n+\n+        async def send_json(self, data):\n+            if not self.is_connected:\n+                return False \n+            try:\n+                await self.ws.send_json(data)\n+            except Exception as ex:\n+                print(ex,flush=True)\n+                self.is_connected = False\n+            return True \n+\n+        async def close(self):\n+            if not self.is_connected:\n+                return True \n+            \n+            await self.ws.close()\n+            self.is_connected = False\n+            \n+            return True  \n+\n+\n     def __init__(self, app):\n         super().__init__(app)\n-        self.sockets = set()\n+        self.sockets = []\n         self.subscriptions = {}\n \n-    async def add(self, ws):\n-        self.sockets.add(ws)\n+    async def add(self, ws, user_uid):\n+        self.sockets.append(self.Socket(ws, await self.app.services.user.get(uid=user_uid)))\n \n-    async def subscribe(self, ws, channel_uid):\n+    async def subscribe(self, ws,channel_uid, user_uid):\n         if not channel_uid in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n-        self.subscriptions[channel_uid].add(ws)\n+        s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n+        self.subscriptions[channel_uid].add(s)\n \n     async def broadcast(self, channel_uid, message):\n-        print(\"BROADCAT!\",message)\n         count = 0\n         subscriptions = set(self.subscriptions.get(channel_uid,[]))\n-        for ws in subscriptions:\n-            try:\n-                await ws.send_json(message)\n-            except Exception as ex:\n-                print(ex,flush=True)\n-                print(\"Deleting socket.\",flush=True)\n-                self.subscriptions[channel_uid].remove(ws)\n+        for s in subscriptions:\n+            if not await s.send_json(message):\n+                self.subscriptions[channel_uid].remove(s)\n                 continue \n             count += 1\n         return count\n     async def delete(self, ws):\n-        try:\n-            self.sockets.remove(ws) \n-        except :\n-            pass \n+        for s in self.sockets:\n+            if s.ws == ws:\n+                await s.close()\n+                self.sockets.remove(s)\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex cd8a9b1..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -86,18 +86,17 @@ async def repair_links(base_url, html_content):\n \n \n async def is_html_content(content: bytes):\n+    if not content:\n+        return False\n     try:\n         content = content.decode(errors=\"ignore\")\n     except:\n         pass\n     marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n-    try:\n-        content = content.lower()\n-        for mark in marks:\n-            if mark in content:\n-                return True\n-    except Exception as ex:\n-        print(ex)\n+    content = content.lower()\n+    for mark in marks:\n+        if mark in content:\n+            return True\n     return False\n \n \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b164c2c..f05f4a5 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -50,9 +50,9 @@ class RPCView(BaseView):\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            await self.services.socket.add(self.ws)\n+            await self.services.socket.add(self.ws,self.view.request.session.get('uid'))\n             async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"])\n+                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"], self.view.request.session.get(\"uid\"))\n             return record \n \n         async def search_user(self, query): \n@@ -74,9 +74,7 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-            print(\"Channel uid:\", channel_uid, flush=True)\n             for message in await self.services.channel_message.offset(channel_uid, offset):\n-                print(message, flush=True)\n                 extended_dict = await self.services.channel_message.to_extended_dict(message)\n                 messages.append(extended_dict)\n             return messages\n@@ -162,9 +160,9 @@ class RPCView(BaseView):\n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n         if self.request.session.get(\"logged_in\"):\n-            await self.services.socket.add(ws)\n+            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n             async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"])\n+                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\"))\n         rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 04e9080..c28b883 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -41,7 +41,6 @@ class SearchUserView(BaseFormView):\n         query = self.request.query.get(\"query\")\n         if query:\n             users = await self.app.services.user.search(query)\n-            print(users, flush=True)\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n@@ -50,6 +49,5 @@ class SearchUserView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            print(\"YES\\n\")\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n         return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 8f13420..d665e58 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -23,8 +23,6 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-\n-        print(await drive_item.to_json(), flush=True)\n         return web.FileResponse(drive_item[\"path\"])\n \n     async def post(self):\n@@ -37,7 +35,6 @@ class UploadView(BaseView):\n \n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n-        print(str(drive), flush=True)\n         extension_types = {\n             \".jpg\": \"image\",\n             \".gif\": \"image\",\n@@ -84,6 +81,5 @@ class UploadView(BaseView):\n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response\n             )\n-            print(drive_item, flush=True)\n \n         return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Improved socket management and messaging for enhanced user experience", "commit": "53be4b060a1fff9cf58c7224dc4522bb0cafa852", "diff": "commit 53be4b060a1fff9cf58c7224dc4522bb0cafa852\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 21:56:26 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a0f1e9b..0b6071d 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -34,30 +34,40 @@ class SocketService(BaseService):\n \n     def __init__(self, app):\n         super().__init__(app)\n-        self.sockets = []\n+        self.sockets = set()\n+        self.users = {}\n         self.subscriptions = {}\n \n     async def add(self, ws, user_uid):\n-        self.sockets.append(self.Socket(ws, await self.app.services.user.get(uid=user_uid)))\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.sockets.add(s)\n+        if not self.users.get(user_uid):\n+            self.users[user_uid] = set() \n+        self.users[user_uid].add(s)\n \n     async def subscribe(self, ws,channel_uid, user_uid):\n+        return\n         if not channel_uid in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n         s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n         self.subscriptions[channel_uid].add(s)\n \n+    async def send_to_user(self, user_uid, message):\n+        count = 0 \n+        for s in self.users.get(user_uid,[]):\n+            if await s.send_json(message):\n+               count += 1 \n+        return count  \n+\n     async def broadcast(self, channel_uid, message):\n         count = 0\n-        subscriptions = set(self.subscriptions.get(channel_uid,[]))\n-        for s in subscriptions:\n-            if not await s.send_json(message):\n-                self.subscriptions[channel_uid].remove(s)\n-                continue \n-            count += 1\n+        async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n+            count += await self.send_to_user(channel_member[\"user_uid\"],message)\n         return count\n+    \n     async def delete(self, ws):\n-        for s in self.sockets:\n-            if s.ws == ws:\n-                await s.close()\n-                self.sockets.remove(s)\n-        \n\\ No newline at end of file\n+        for s in [sock for sock in self.sockets if sock.ws == ws]:\n+            await s.close()\n+            self.sockets.remove(s)\n+            \n+    \n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 39e6fc3..f9c4761 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(\"Cache store! New version:\", self.version, flush=True)\n+            print(f\"Cache store! {len(self.lru)} items. New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex f05f4a5..d664435 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -91,9 +91,9 @@ class RPCView(BaseView):\n                 })\n             return channels\n \n-        async def send_message(self, room, message):\n+        async def send_message(self, channel_uid, message):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid, room, message)\n+            await self.services.chat.send(self.user_uid, channel_uid, message)\n             return True \n \n         async def echo(self, *args):"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Add channel tag to RPC view", "commit": "9c3abdec2613c4d492c363cca8a07882dd3d8135", "diff": "commit 9c3abdec2613c4d492c363cca8a07882dd3d8135\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 23:01:43 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d664435..901ee14 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -83,9 +83,11 @@ class RPCView(BaseView):\n             self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n+                channel = await self.services.channel.get(uid=subscription['uid'])\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n+                    \"tag\": channel[\"tag\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n                     \"is_read_only\": subscription[\"is_read_only\"]\n                 })"}
{"repo": ".", "date": "2025-02-15", "line": "fix: Correct channel UID retrieval in RPCView", "commit": "d1396801c05688e15ad7f1082dab2576b9a2b011", "diff": "commit d1396801c05688e15ad7f1082dab2576b9a2b011\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 23:03:45 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 901ee14..06d903c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -83,7 +83,7 @@ class RPCView(BaseView):\n             self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n-                channel = await self.services.channel.get(uid=subscription['uid'])\n+                channel = await self.services.channel.get(uid=subscription['channel_uid'])\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed video using template for links", "commit": "7c4334fe7b5b7e6ba44627a4f084638af51dd44c", "diff": "commit 7c4334fe7b5b7e6ba44627a4f084638af51dd44c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:23:11 2025 +0100\n\n    Updated video player.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex a543dd7..e8935b7 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -17,6 +17,10 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n+            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Added media embedding for images, videos, and YouTube links", "commit": "c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2", "diff": "commit c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:41:33 2025 +0100\n\n    Updated video player.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex e8935b7..60b176d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -23,7 +23,35 @@ def set_link_target_blank(text):\n             element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)\n-    \n+\n+def embed_youtube(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+def embed_image(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+        for extension in [\".png\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n+            if extension in element.attrs['href'].lower():\n+                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+def embed_media(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+        for extension in [\".mp4\", \".mp3\", \".wav\", \".ogg\", \".webm\", \".flac\", \".aac\",\".mpg\",\".avi\",\".wmv\"]:\n+            if extension in element.attrs['href'].lower():\n+                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n+                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+\n \n def linkify_https(text):\n@@ -83,7 +111,11 @@ class LinkifyExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return linkify_https(caller())\n+        result =  linkify_https(caller())\n+        result = embed_media(result)\n+        result = embed_image(result)\n+        result = embed_youtube(result)\n+        return result\n \n class PythonExtension(Extension):\n     tags = {\"py3\"}"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Added linkify_https to template rendering", "commit": "263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc", "diff": "commit 263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:49:37 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 60b176d..a17ee71 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -111,7 +111,7 @@ class LinkifyExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        result =  linkify_https(caller())\n+        result = linkify_https(caller())\n         result = embed_media(result)\n         result = embed_image(result)\n         result = embed_youtube(result)"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Added blank target and referrer policy to links", "commit": "7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3", "diff": "commit 7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:53:28 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex a17ee71..6410a9f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -17,10 +17,10 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n-            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Handle file extensions in upload URLs", "commit": "be956a13db0941008802701196ca5e3870ebf2aa", "diff": "commit be956a13db0941008802701196ca5e3870ebf2aa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 23:05:56 2025 +0100\n\n    Changes\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3020584..d77606f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -85,7 +85,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n-        self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n         self.router.add_view(\"/search-user.json\", SearchUserView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex d665e58..05ffcaa 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -75,8 +75,7 @@ class UploadView(BaseView):\n             await self.services.drive_item.save(drive_item)\n             response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            if type_ == \"image\":\n-                response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            response = \"[url](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Ensure images, videos, and iframes within messages are responsive", "commit": "ea4196af8f7d7e0c97c004a07817bdc1dac999f5", "diff": "commit ea4196af8f7d7e0c97c004a07817bdc1dac999f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 04:49:09 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7483432..a21ad41 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -161,8 +161,11 @@ hyphens: auto;\n     max-width: 100%;\n }\n \n-.message-content img {\n-  max-width: 100%; \n+.message-content {\n+\n+    img, video, iframe {\n+        max-width: 100%; \n+    }\n }\n \n .chat-messages .message .message-content .time {"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improved media handling in message content", "commit": "f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f", "diff": "commit f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 04:51:21 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex a21ad41..83ae235 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -163,7 +163,7 @@ hyphens: auto;\n \n .message-content {\n \n-    img, video, iframe {\n+    img, video, iframe, div {\n         max-width: 100%; \n     }\n }\n@@ -231,7 +231,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .message.switch-user {\n-  .text img {\n+  .text img,iframe, video {\n     max-width: 90%;\n     border-radius: 20px;\n   }"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement scroll to bottom on new message", "commit": "2e69ac5921c16ea0cda1a1b7c84dd63ff458db62", "diff": "commit 2e69ac5921c16ea0cda1a1b7c84dd63ff458db62\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 10:51:26 2025 +0100\n\n    Added scroll only when has reached bottom.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1430a04..ee67a7c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -45,11 +45,18 @@\n             time.innerText = app.timeDescription(time.dataset.created_at);\n         });\n     }\n-\n-    function updateLayout() {\n+    function isElementVisible(element) {\n+        const rect = element.getBoundingClientRect();\n+        return (\n+            rect.top >= 0 &&\n+            rect.left >= 0 &&\n+            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+        );\n+    }\n+    function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        \n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -60,8 +67,10 @@\n                 message.classList.remove(\"switch-user\");\n             }\n         });\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        if(doScrollDown){ \n+            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        }\n \n     }\n \n@@ -73,6 +82,11 @@\n         if (data.username !== \"{{ user.username.value }}\") {\n             app.playSound(0);\n         }\n+        \n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n+        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+\n \n         const message = document.createElement(\"div\");\n         message.dataset.color = data.color;\n@@ -81,14 +95,14 @@\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n-        updateLayout();\n+        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n         setTimeout(()=>{\n-            updateLayout()\n+            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n         },1000)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\n-    updateLayout();\n+    updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement timestamped pagination and focus textbox.", "commit": "8c33bc63d6cc623d0782f14c96a42c612accbc75", "diff": "commit 8c33bc63d6cc623d0782f14c96a42c612accbc75\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 13:37:05 2025 +0100\n\n    Focus textbox. Updated pagination.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex c421b5e..953881e 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -51,12 +51,20 @@ class ChannelMessageService(BaseService):\n             \"username\": user['username'] \n         }\n \n-    async def offset(self, channel_uid, page=0, page_size=30):\n+    async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):\n         results = []\n         offset = page * page_size \n         try:\n-            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n-                results.append(model)\n+            if not timestamp:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+                    results.append(model)\n+            elif page >= 0:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at > :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                    results.append(model)\n+            else: \n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                    results.append(model)\n+\n         except: \n             pass\n         results.sort(key=lambda x: x['created_at'])\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ee67a7c..34972e7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -38,6 +38,7 @@\n                 }\n             }\n         });\n+        textBox.focus();\n     }\n \n     function updateTimes() {\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 06d903c..07893e1 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -71,10 +71,10 @@ class RPCView(BaseView):\n                 del record['email']\n             return record \n \n-        async def get_messages(self, channel_uid, offset=0):\n+        async def get_messages(self, channel_uid, offset=0,timestamp = None):\n             self._require_login()\n             messages = []\n-            for message in await self.services.channel_message.offset(channel_uid, offset):\n+            for message in await self.services.channel_message.offset(channel_uid, offset or 0,timestamp or None):\n                 extended_dict = await self.services.channel_message.to_extended_dict(message)\n                 messages.append(extended_dict)\n             return messages"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "477ca5917a59d3720a6a5ae01b307dec1a74cfaf", "diff": "commit 477ca5917a59d3720a6a5ae01b307dec1a74cfaf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:42:59 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 953881e..9683631 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -55,14 +55,14 @@ class ChannelMessageService(BaseService):\n         results = []\n         offset = page * page_size \n         try:\n-            if not timestamp:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+            if timestamp:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=timestamp)):\n                     results.append(model)\n-            elif page >= 0:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at > :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+            elif page > 0:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n                     results.append(model)\n             else: \n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n                     results.append(model)\n \n         except: \ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 34972e7..1415497 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -55,6 +55,32 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n+\n+    const messagesContainer = document.querySelector(\".chat-messages\");\n+    async function loadExtra() {\n+    \n+        const fourthMessage = messagesContainer.querySelector(\".chat-messages :nth-child(4)\");\n+        if(!fourthMessage){\n+            return\n+        }\n+        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n+        if(fourthMessage.dataset.seen){\n+            return \n+        }\n+\n+        if(isElementVisible(fourthMessage)){\n+            fourthMessage.dataset.seen = true\n+            console.info(channelUid, fourthMessage.dataset.created_at)\n+            const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n+            messages.forEach((message) => {\n+                firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+            })\n+            console.info(messages)\n+        }\n+    }\n+    messagesContainer.addEventListener(\"scroll\",()=>{\n+        loadExtra()\n+    });\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "9e3b9ae326b6ec632f3280018ea68d1896645b9a", "diff": "commit 9e3b9ae326b6ec632f3280018ea68d1896645b9a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:50:20 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1415497..8a06293 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,23 +59,21 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const fourthMessage = messagesContainer.querySelector(\".chat-messages :nth-child(4)\");\n-        if(!fourthMessage){\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(15)\");\n+        if(!offsetMessage){\n             return\n         }\n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(fourthMessage.dataset.seen){\n+        if(offsetMessage.dataset.seen){\n             return \n         }\n \n-        if(isElementVisible(fourthMessage)){\n-            fourthMessage.dataset.seen = true\n-            console.info(channelUid, fourthMessage.dataset.created_at)\n+        if(isElementVisible(offsetMessage)){\n+            offsetMessage.dataset.seen = true\n             const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n-            console.info(messages)\n         }\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use offsetMessage timestamp for infinite scroll", "commit": "33bc695cda6bf5889d802129117ed59992b87143", "diff": "commit 33bc695cda6bf5889d802129117ed59992b87143\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:52:19 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8a06293..10581cc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -70,7 +70,7 @@\n \n         if(isElementVisible(offsetMessage)){\n             offsetMessage.dataset.seen = true\n-            const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n+            const messages = await app.rpc.get_messages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected method name for fetching messages", "commit": "aa5703e62f891fa7db09f07e6ce060875f5990d3", "diff": "commit aa5703e62f891fa7db09f07e6ce060875f5990d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:52:30 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 10581cc..01a972e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -70,7 +70,7 @@\n \n         if(isElementVisible(offsetMessage)){\n             offsetMessage.dataset.seen = true\n-            const messages = await app.rpc.get_messages(channelUid, 1, offsetMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected offset calculation for infinite scroll", "commit": "1792686531fb440d9f453422bfa648c890c255d1", "diff": "commit 1792686531fb440d9f453422bfa648c890c255d1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:54:25 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 01a972e..6fc488a 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,7 +59,7 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(15)\");\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(1)\");\n         if(!offsetMessage){\n             return\n         }"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust offset for infinite scroll", "commit": "3ee7c6d8024245933569fdaf3f99a71afd14fe8f", "diff": "commit 3ee7c6d8024245933569fdaf3f99a71afd14fe8f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:56:35 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 6fc488a..cf7b7cb 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,7 +59,7 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(1)\");\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(10)\");\n         if(!offsetMessage){\n             return\n         }"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "95a8a458420dd2ebdbd6f7c03bd58c649985933e", "diff": "commit 95a8a458420dd2ebdbd6f7c03bd58c649985933e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:03:40 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex cf7b7cb..7740191 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -55,26 +55,41 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n+    \n \n     const messagesContainer = document.querySelector(\".chat-messages\");\n+    function isScrolledPastHalf() {\n+        let scrollTop = messagesContainer.scrollTop;\n+        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n+\n+        if (scrollTop < scrollableHeight / 2) {\n+            return false;\n+        }\n+        return true;\n+    }\n+    let isLoadingExtra = false;\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(10)\");\n-        if(!offsetMessage){\n-            return\n-        }\n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(offsetMessage.dataset.seen){\n+        if(isLoadingExtra){\n             return \n         }\n-\n-        if(isElementVisible(offsetMessage)){\n-            offsetMessage.dataset.seen = true\n+        if(isScrolledPastHalf()){\n+            isLoadingExtra = true\n+        \n+        }\n+            \n             const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-            })\n-        }\n+        isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "162f89f9d0f7e304d355dd8f626ce2430dd840bf", "diff": "commit 162f89f9d0f7e304d355dd8f626ce2430dd840bf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:04:59 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 7740191..c4e6e13 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -89,7 +89,8 @@\n             const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-        isLoadingExtra = false;\n+            })\n+            isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use first message timestamp for infinite scroll", "commit": "104ee277669ee2cd55eb97b674f7a2432d31bb5a", "diff": "commit 104ee277669ee2cd55eb97b674f7a2432d31bb5a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:06:10 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c4e6e13..3398957 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -86,7 +86,7 @@\n         \n         }\n             \n-            const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages before scrolling past half", "commit": "60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b", "diff": "commit 60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:09:42 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3398957..83e50e7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,10 +81,12 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(isScrolledPastHalf()){\n+        if(!isScrolledPastHalf()){\n+            return\n+        }\n             isLoadingExtra = true\n         \n-        }\n+        \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n             messages.forEach((message) => {"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling based on scroll position", "commit": "2fb6be753efa7f2cc1e0183551a1ad655388b970", "diff": "commit 2fb6be753efa7f2cc1e0183551a1ad655388b970\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:11:14 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 83e50e7..9b0ed6c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,7 +81,7 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(!isScrolledPastHalf()){\n+        if(isScrolledPastHalf()){\n             return\n         }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "24cd378c9d8f857f4f28af1069a2f5523a6441d8", "diff": "commit 24cd378c9d8f857f4f28af1069a2f5523a6441d8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:21:03 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 83ae235..9e5a02b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -102,10 +102,15 @@ a {\n .chat-messages {\n   flex: 1;\n   overflow-y: auto;\n+  scrollbar-width: none;\n+  -ms-overflow-style: none;\n   padding: 10px;\n   height: 200px;\n }\n+.chat-messages::-webkit-scrollbar {\n+  display: none;\n+}\n \n .chat-messages .message {\n   display: flex;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use scroll instead of auto for chat messages", "commit": "8b98935d11496d51a0007db4b78391dde7a69163", "diff": "commit 8b98935d11496d51a0007db4b78391dde7a69163\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:26:16 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9e5a02b..a928a72 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,7 +101,7 @@ a {\n \n .chat-messages {\n   flex: 1;\n-  overflow-y: auto;\n+  overflow-y: scroll;\n   scrollbar-width: none;\n   -ms-overflow-style: none;\n   padding: 10px;"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar and improve chat styling", "commit": "5b03ecda3f3f9ae515dd00a4e421255535a2f215", "diff": "commit 5b03ecda3f3f9ae515dd00a4e421255535a2f215\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:30:50 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex a928a72..7be47ca 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -79,17 +79,20 @@ a {\n   display: flex;\n   flex-direction: column;\n+  overflow: hidden;\n }\n \n .chat-header {\n   padding: 10px 20px;\n+  user-select: none;\n }\n \n .chat-header h2 {\n   font-size: 1.2em;\n+\n }\n \n .message-list {\n@@ -242,6 +245,7 @@ input[type=\"text\"], .chat-input textarea {\n   }\n   \n   .avatar {\n+    user-select: none;\n     opacity: 1;\n   }\n \n@@ -258,7 +262,7 @@ input[type=\"text\"], .chat-input textarea {\n \n ::-webkit-scrollbar {\n-  width: 6px;\n+    display:none;\n }\n \n ::-webkit-scrollbar-track {"}
{"repo": ".", "date": "2025-02-17", "line": "style: Removed scrollbar visibility for cleaner chat appearance", "commit": "3230c9f93bf9c3ae1b27474eac1cfc35f626d387", "diff": "commit 3230c9f93bf9c3ae1b27474eac1cfc35f626d387\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:32:28 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7be47ca..400c392 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -280,7 +280,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .chat-messages {\n-  scrollbar-width: thin;\n+  scrollbar-width: none;\n }"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improve infinite scroll trigger position", "commit": "6c58f4b26c628881aee5cbe0597ba489f705e42f", "diff": "commit 6c58f4b26c628881aee5cbe0597ba489f705e42f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:36:45 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 9b0ed6c..e236f5c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 2) {\n+        if (scrollTop < scrollableHeight / 4) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust scroll position threshold for infinite scrolling", "commit": "bc8a296223f3a2c6e07b126a78373aa5bb40399d", "diff": "commit bc8a296223f3a2c6e07b126a78373aa5bb40399d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:36:57 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e236f5c..ca03375 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 4) {\n+        if (scrollTop < scrollableHeight / 5) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll position threshold for infinite scrolling", "commit": "c77d2fb782258f787c5b52e3b27c5a3b0d468903", "diff": "commit c77d2fb782258f787c5b52e3b27c5a3b0d468903\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:42:45 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ca03375..b7ba523 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 5) {\n+        if (scrollTop > scrollableHeight / 1.2) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll threshold for infinite scrolling", "commit": "2e86ca2a3f1a4a8c746eecb46038a216b9706cdf", "diff": "commit 2e86ca2a3f1a4a8c746eecb46038a216b9706cdf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:44:31 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex b7ba523..f400996 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 1.2) {\n+        if (scrollTop > scrollableHeight / 5) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scroll for message container", "commit": "1a608d8cfb1a3dcef9591b214fc54904615148bf", "diff": "commit 1a608d8cfb1a3dcef9591b214fc54904615148bf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:45:46 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f400996..2c64c37 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,9 +62,9 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 5) {\n-            return false;\n-        }\n         return true;\n     }\n     let isLoadingExtra = false;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages when scrolled past half", "commit": "f0d76bd46af06637a21526c58d97f4b4d57f87dd", "diff": "commit f0d76bd46af06637a21526c58d97f4b4d57f87dd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:48:03 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2c64c37..3698913 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,10 +62,10 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        return true;\n+        if (scrollTop < scrollableHeight / 4) {\n+            return true;\n+        }\n+        return false;\n     }\n     let isLoadingExtra = false;\n     async function loadExtra() {\n@@ -81,7 +81,7 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(isScrolledPastHalf()){\n+        if(!isScrolledPastHalf()){\n             return\n         }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling functionality", "commit": "1b6ebf50080b0b86256e639031f36da92b8990b2", "diff": "commit 1b6ebf50080b0b86256e639031f36da92b8990b2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:49:29 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3698913..bf2d1dc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,9 +81,9 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(!isScrolledPastHalf()){\n-            return\n-        }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll trigger to load more messages", "commit": "6bdc6a7347a492f155629458c9b277cc16e04666", "diff": "commit 6bdc6a7347a492f155629458c9b277cc16e04666\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:57:48 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex bf2d1dc..09b3a14 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 4) {\n+        if (scrollTop > scrollableHeight / 2) {\n             return true;\n         }\n         return false;\n@@ -81,9 +81,9 @@\n         if(isLoadingExtra){\n             return \n         }\n+        if(!isScrolledPastHalf()){\n+           return\n+        }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correct infinite scroll trigger", "commit": "c042af8b800879ecfbb817089119aab75d839c32", "diff": "commit c042af8b800879ecfbb817089119aab75d839c32\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:00:35 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 09b3a14..65bc3af 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 2) {\n+        if (scrollTop < scrollableHeight / 2) {\n             return true;\n         }\n         return false;\n@@ -89,10 +89,11 @@\n         \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n+            isLoadingExtra = false;\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n-            isLoadingExtra = false;\n+            updateLayout(false);\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3", "diff": "commit bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:17:17 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 65bc3af..adf0e55 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -89,11 +89,13 @@\n         \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n-            isLoadingExtra = false;\n+\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n             updateLayout(false);\n+\n+            isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correct initial message retrieval for infinite scroll", "commit": "2595594c3a99f6613e8e2194977fb1707c9f8b98", "diff": "commit 2595594c3a99f6613e8e2194977fb1707c9f8b98\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:20:32 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex adf0e55..2e0ce52 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -88,7 +88,7 @@\n         \n         \n             \n-            const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Added comments and improved message loading logic", "commit": "6555e4f8266b01963cfd660a4e175b01ab615c0c", "diff": "commit 6555e4f8266b01963cfd660a4e175b01ab615c0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:31:18 2025 +0100\n\n    Refactor.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2e0ce52..83e2ff9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,4 +1,15 @@\n-{% extends \"app.html\" %} \n+{% comment \"Written by retoor@molodetz.nl\" %}\n+\n+{% comment \"This is a chat interface template using Jinja2 template syntax and JavaScript for handling user interactions, loading messages, and updating UI elements dynamically.\" %}\n+\n+{% comment \"There are no external imports that are not part of the templating language itself.\" %}\n+\n+{% comment \"MIT License\" %}\n+{% comment \"Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\" %}\n+{% comment \"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\" %}\n+{% comment \"THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\" %}\n+\n+{% extends \"app.html\" %}\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n@@ -46,6 +57,7 @@\n             time.innerText = app.timeDescription(time.dataset.created_at);\n         });\n     }\n+\n     function isElementVisible(element) {\n         const rect = element.getBoundingClientRect();\n         return (\n@@ -56,8 +68,8 @@\n         );\n     }\n     \n-\n     const messagesContainer = document.querySelector(\".chat-messages\");\n+\n     function isScrolledPastHalf() {\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n@@ -67,42 +79,36 @@\n         }\n         return false;\n     }\n+\n     let isLoadingExtra = false;\n+\n     async function loadExtra() {\n-    \n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(isLoadingExtra){\n-            return \n+        if (isLoadingExtra) {\n+            return;\n         }\n-        if(!isScrolledPastHalf()){\n-           return\n+        if (!isScrolledPastHalf()) {\n+           return;\n         }\n-            isLoadingExtra = true\n         \n+        isLoadingExtra = true;\n         \n-            \n-            const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n+        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n-            messages.forEach((message) => {\n-                firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-            })\n-            updateLayout(false);\n+        messages.forEach((message) => {\n+            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+        })\n+        updateLayout(false);\n \n-            isLoadingExtra = false;\n+        isLoadingExtra = false;\n     }\n-    messagesContainer.addEventListener(\"scroll\",()=>{\n-        loadExtra()\n+\n+    messagesContainer.addEventListener(\"scroll\", () => {\n+        loadExtra();\n     });\n+\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -114,10 +120,9 @@\n             }\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if(doScrollDown){ \n+        if (doScrollDown) { \n             lastMessage.scrollIntoView({ inline: \"nearest\" });\n         }\n-\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -133,7 +138,6 @@\n         const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n-\n         const message = document.createElement(\"div\");\n         message.dataset.color = data.color;\n         message.dataset.created_at = data.created_at;\n@@ -142,14 +146,12 @@\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);\n-        setTimeout(()=>{\n+        setTimeout(() => {\n             updateLayout(doScrollDownBecauseLastMessageIsVisible)\n-        },1000)\n+        }, 1000);\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n-{% endblock %}\n-\n-\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Remove unnecessary comments from web.html", "commit": "2ab4341d0099799a84c0df6d91de33e1c5f69470", "diff": "commit 2ab4341d0099799a84c0df6d91de33e1c5f69470\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:34:24 2025 +0100\n\n    Refactor.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 83e2ff9..910f601 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,14 +1,3 @@\n-{% comment \"Written by retoor@molodetz.nl\" %}\n-\n-{% comment \"This is a chat interface template using Jinja2 template syntax and JavaScript for handling user interactions, loading messages, and updating UI elements dynamically.\" %}\n-\n-{% comment \"There are no external imports that are not part of the templating language itself.\" %}\n-\n-{% comment \"MIT License\" %}\n-{% comment \"Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\" %}\n-{% comment \"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\" %}\n-{% comment \"THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\" %}\n-\n {% extends \"app.html\" %}\n \n {% block main %}\n@@ -154,4 +143,4 @@\n     initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n-{% endblock %}\n\\ No newline at end of file\n+{% endblock %}"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Updated manifest and app.html for PWA support", "commit": "e21880b4f5fd15259e09c207ce54d8e86bd61ac7", "diff": "commit e21880b4f5fd15259e09c207ce54d8e86bd61ac7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:31:17 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 6d98b90..3a7200f 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -1,7 +1,20 @@\n {\n+    \"id\": \"snek\",\n     \"name\": \"Snek\",\n     \"description\": \"Danger noodle\",\n     \"display\": \"standalone\",\n+    \"orientation\": \"portrait\",\n+    \"scope\": \"/web.html\",\n+    \"related_applications\": [],\n+    \"prefer_related_applications\": false,\n+    \"screenshots\": [],\n+    \"dir\": \"ltr\",\n+    \"lang\": \"en-US\",\n+    \"launch_path\": \"/web.html\",\n+    \"display_override\": [\"browser\"],\n+    \"short_name\": \"Snek\",\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n@@ -10,4 +23,4 @@\n         \"sizes\": \"512x512\"\n       }\n     ]\n-  }\n\\ No newline at end of file\n+  }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 8caaef8..42ba78f 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -3,9 +3,12 @@\n <head>\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <link rel=\"manifest\" href=\"/manifest.json\" />\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <!-- \n   <script src=\"/push.js\"></script>\n+  -->\n   <script src=\"/fancy-button.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n   <script src=\"/generic-form.js\"></script>\n@@ -13,7 +16,7 @@\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n-  <link rel=\"manifest\" href=\"/manifest.json\" />\n+\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\n <body>\n@@ -45,20 +48,22 @@\n   </main>\n   <script>\n let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-            document.addEventListener(\"DOMContentLoaded\", () => {\n-            alert(\"Jaaah\") \n+        window.addEventListener(\"beforeinstallprompt\", (e) => {\n+            installPrompt = e;\n             const button = document.getElementById(\"install-button\")\n+            \n+            button.style.display = 'inline-block'\n+            \n             button.addEventListener(\"click\", async ()=>{  \n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n+                const result = await installPrompt.prompt()\n+                console.info(result.outcome)\n             })\n-            button.style.display = 'inline-block'\n+\n             \n             })\n-        });\n+    \n         ;\n     </script>\n </body>"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Removed unnecessary display_override in manifest.json", "commit": "6c7266f20403f1f190c8b41f22d653c041dbbc77", "diff": "commit 6c7266f20403f1f190c8b41f22d653c041dbbc77\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:37:15 2025 +0100\n\n    Fixed.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 3a7200f..21acb7a 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -13,7 +13,6 @@\n     \"dir\": \"ltr\",\n     \"lang\": \"en-US\",\n     \"launch_path\": \"/web.html\",\n-    \"display_override\": [\"browser\"],\n     \"short_name\": \"Snek\",\n     \"start_url\": \"/web.html\",\n     \"icons\": ["}
{"repo": ".", "date": "2025-02-17", "line": "feat: Added 192x192 icon to manifest", "commit": "c745f609976397de0fb0cf7dec80205239b44b87", "diff": "commit c745f609976397de0fb0cf7dec80205239b44b87\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:53:20 2025 +0100\n\n    Update manifest.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 21acb7a..6af4db1 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -17,6 +17,11 @@\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n+        \"src\": \"/image/snek1.png\",\n+        \"type\": \"image/png\",\n+        \"sizes\": \"192x192\"\n+      },\n+        {\n         \"src\": \"/image/snek1.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"512x512\""}
{"repo": ".", "date": "2025-02-18", "line": "refactor: Switch to python -m snek.app and asyncio debugging", "commit": "3ccbe8be5c604d2683cf55553d1f11c674f6b930", "diff": "commit 3ccbe8be5c604d2683cf55553d1f11c674f6b930\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 12:28:49 2025 +0100\n\n    Updated asyncio debugging.\n\ndiff --git a/compose.yml b/compose.yml\nindex ced4111..a1079dd 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,8 +9,8 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n      - PYTHONUNBUFFERED=\"1\"\n-    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n   snecssh:\n     build:\n       context: .\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex d77606f..4ce71ba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,5 +1,7 @@\n import pathlib\n \n+import asyncio\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,\n@@ -29,6 +31,7 @@ from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n \n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -121,8 +124,11 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n+async def main():\n+    loop = asyncio.get_event_loop()\n+    loop.set_debug(True)\n+    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":\n-\n-    web.run_app(app, port=8081, host=\"0.0.0.0\")\n+    asyncio.run(main())"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Standardize environment variables and add logging", "commit": "ebb520dd4a80b513d1eb6fc6ce90e6b46f905100", "diff": "commit ebb520dd4a80b513d1eb6fc6ce90e6b46f905100\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 12:38:44 2025 +0100\n\n    Updated asyncio debugging.\n\ndiff --git a/compose.yml b/compose.yml\nindex a1079dd..0090fff 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -7,8 +7,8 @@ services:\n     volumes:\n       - ./:/code\n     environment:\n-     - PYTHONDONTWRITEBYTECODE=\"1\"\n-     - PYTHONUNBUFFERED=\"1\"\n+     - PYTHONDONTWRITEBYTECODE=1\n+     - PYTHONUNBUFFERED=1\n     entrypoint: [\"python\",\"-m\",\"snek.app\"]\n   snecssh:\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 4ce71ba..ab337ba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,7 +1,9 @@\n import pathlib\n-\n import asyncio\n \n+import logging \n+logging.basicConfig(level=logging.DEBUG)\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,"}
{"repo": ".", "date": "2025-02-18", "line": "feat: Added profiler functionality for performance analysis", "commit": "c6620ad70afce9407c16793de8ab4fea35523d81", "diff": "commit c6620ad70afce9407c16793de8ab4fea35523d81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:29:33 2025 +0100\n\n    Added profiler.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ab337ba..0fc1016 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,7 @@ import pathlib\n import asyncio\n \n import logging \n+\n logging.basicConfig(level=logging.DEBUG)\n \n from aiohttp import web\n@@ -32,7 +33,7 @@ from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n-\n+from snek.system.profiler import profiler_handler\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -43,7 +44,6 @@ async def session_middleware(request, handler):\n     response = await handler(request)\n     return response\n \n-\n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n@@ -77,6 +77,7 @@ class Application(BaseApplication):\n             name=\"static\",\n             show_index=True,\n         )\n+        self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/logout.json\", LogoutView)\n@@ -128,8 +129,6 @@ class Application(BaseApplication):\n \n async def main():\n-    loop = asyncio.get_event_loop()\n-    loop.set_debug(True)\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nnew file mode 100644\nindex 0000000..824208a\n--- /dev/null\n+++ b/src/snek/system/profiler.py\n@@ -0,0 +1,42 @@\n+import cProfile\n+import pstats\n+import sys \n+from aiohttp import web\n+profiler = None\n+import io \n+\n+\n+@web.middleware\n+async def profile_middleware(request, handler):\n+    global profiler \n+    if not profiler:\n+        profiler = cProfile.Profile()\n+    profiler.enable()\n+    response = await handler(request)\n+    profiler.disable()\n+    stats = pstats.Stats(profiler, stream=sys.stdout)\n+    stats.sort_stats('cumulative')\n+    stats.print_stats() \n+    return response\n+\n+async def profiler_handler(request):\n+    output = io.StringIO()\n+    stats = pstats.Stats(profiler, stream=output)\n+    stats.sort_stats('cumulative')\n+    stats.print_stats()\n+    return web.Response(text=output.getvalue())\n+\n+class Profiler:\n+\n+    def __init__(self):\n+        global profiler \n+        if profiler is None:\n+            profiler = cProfile.Profile()\n+        self.profiler = profiler\n+\n+    async def __aenter__(self):\n+        self.profiler.enable()    \n+    \n+    async def __aexit__(self, *args, **kwargs):\n+        self.profiler.disable()\n+\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 07893e1..05e447d 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -12,6 +12,7 @@ from snek.system.view import BaseView\n import traceback\n import json\n from snek.system.model import now \n+from snek.system.profiler import Profiler\n \n class RPCView(BaseView):\n \n@@ -169,8 +170,10 @@ class RPCView(BaseView):\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n-                    await rpc(msg.json())\n+                    async with Profiler():\n+                        await rpc(msg.json())\n                 except Exception as ex:\n+                    print(ex, flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Sort profiler stats by query parameter", "commit": "91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3", "diff": "commit 91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:47:34 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex 824208a..c5831e9 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -22,7 +22,8 @@ async def profile_middleware(request, handler):\n async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n-    stats.sort_stats('cumulative')\n+    sort_by = request.query.get(\"sort\", \"tot. percall\")\n+    stats.sort_stats(sory_by)\n     stats.print_stats()\n     return web.Response(text=output.getvalue())"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Corrected typo in profiler sorting", "commit": "60404c6fd31894f3fbb6ce31ba48f1750101748f", "diff": "commit 60404c6fd31894f3fbb6ce31ba48f1750101748f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:48:48 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex c5831e9..193bbb7 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -23,7 +23,7 @@ async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n     sort_by = request.query.get(\"sort\", \"tot. percall\")\n-    stats.sort_stats(sory_by)\n+    stats.sort_stats(sort_by)\n     stats.print_stats()\n     return web.Response(text=output.getvalue())"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Added a1 emoji and long emoji", "commit": "736123c4aa313c51a7e0daee8cdd6dc7583547fd", "diff": "commit 736123c4aa313c51a7e0daee8cdd6dc7583547fd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 22:32:15 2025 +0100\n\n    Aadded a1\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 6410a9f..1b63aec 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -9,6 +9,63 @@ from jinja2.nodes import Const\n \n emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n \n+emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\"\"\"] = {\"en\": \":a1:\",\"status\":2,\"E\":0.6, \"alias\":[\":a1:\"]}\n+\n def set_link_target_blank(text):\n     soup = BeautifulSoup(text, 'html.parser')"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Updated server startup with Gunicorn for production deployment", "commit": "2ad5a7b1f49704baf7b890fcfda7a87fddd456f7", "diff": "commit 2ad5a7b1f49704baf7b890fcfda7a87fddd456f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:20:10 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0090fff..0a42856 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,8 +9,8 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:\n       context: ."}
{"repo": ".", "date": "2025-02-19", "line": "fix: Reduced Gunicorn worker count", "commit": "e06824f4ec703388b7d55beeb5f1b3ef12452226", "diff": "commit e06824f4ec703388b7d55beeb5f1b3ef12452226\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:21:08 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0a42856..17bcf22 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:"}
{"repo": ".", "date": "2025-02-19", "line": "refactor: Increased Gunicorn workers to 4", "commit": "821db3cb1a67c20a968ac1dd8ecc4263e511cf16", "diff": "commit 821db3cb1a67c20a968ac1dd8ecc4263e511cf16\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:23:24 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 17bcf22..0a42856 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0fc1016..8a3481e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -127,8 +127,10 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n+\n+\n async def main():\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":"}
{"repo": ".", "date": "2025-02-20", "line": "feat: Added database indexes and updated UI elements\n\nThis commit introduces database indexes for improved query performance and updates several UI elements for better user experience. It also includes minor fixes to static assets and template rendering.\n", "commit": "3623286a9dfba330612c42e579abcca63ab186ed", "diff": "commit 3623286a9dfba330612c42e579abcca63ab186ed\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 20 05:56:31 2025 +0100\n\n    Changed server.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 8a3481e..3a61c2b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -65,6 +65,13 @@ class Application(BaseApplication):\n         self.setup_router()\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n+        if not self.db[\"user\"].has_index(\"username\"):\n+            self.db[\"user\"].create_index(\"username\", unique=True)\n+        if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n+            self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n+        if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n+            self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+    \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 400c392..e42784b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -111,6 +111,21 @@ a {\n   height: 200px;\n }\n+.container {\n+  flex: 1;\n+  padding: 10px;\n+  ul {\n+    list-style: none;\n+    margin: 0;\n+    padding: 0;\n+  }\n+  a {\n+        font-size: 20px;\n+      }\n+ \n+}\n+\n .chat-messages::-webkit-scrollbar {\n   display: none;\n }\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 6af4db1..faa7381 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -4,7 +4,7 @@\n     \"description\": \"Danger noodle\",\n     \"display\": \"standalone\",\n     \"orientation\": \"portrait\",\n-    \"scope\": \"/web.html\",\n+    \"scope\": \"/\",\n     \"related_applications\": [],\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1b63aec..22d007d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -74,10 +74,6 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n \n     return str(soup)\n \n@@ -93,7 +89,7 @@ def embed_youtube(text):\n def embed_image(text):\n     soup = BeautifulSoup(text, 'html.parser') \n     for element in soup.find_all(\"a\"):  \n-        for extension in [\".png\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n+        for extension in [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n             if extension in element.attrs['href'].lower():\n                 embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n                 element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 42ba78f..88a6335 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -34,7 +34,7 @@\n   <main>\n     {% block sidebar %}\n     <aside class=\"sidebar\">\n-      <h2 class=\"no-select\">Chat Rooms</h2>\n+      <h2 class=\"no-select\">Channels</h2>\n       <ul>\n         {% for channel in channels %}\n         <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 8bce656..b6a0439 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -2,25 +2,27 @@\n \n {% block title %}Search{% endblock %}\n \n-{% block main %}    \n+{% block main %}\n \n-    <section class=\"chat-area\">\n-        <div class=\"chat-header\"><h2>Search user</h2></div>\n-        <div class=\"chat-messages\">\n+<section class=\"chat-area\">\n+    <div class=\"chat-header\">\n+        <h2>Search user</h2>\n+    </div>\n+    <div class=\"container\">\n         <form method=\"get\" action=\"/search-user.html\">\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n-            <fancy-button size=\"auto\"  text=\"Search\" url=\"submit\"></fancy-button>\n+            <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>\n         </form>\n         <ul>\n-        {% for user in users %}\n-    <li>\n-        <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n-    </li>\n-    \n-    {% endfor %}\n-    </ul> \n+            {% for user in users %}\n+            <li>\n+                <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n+            </li>\n \n- \n-</div>\n-    </section>\n-{% endblock %}\n+            {% endfor %}\n+        </ul>\n+\n+\n+    </div>\n+</section>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 05e447d..d98e53b 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -109,6 +109,24 @@ class RPCView(BaseView):\n             lowercase = query.lower()\n             if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n+            records = [dict(record) async for record in self.services.channel.query(args[0])]\n+            for record in records:\n+                try:\n+                    del record['email']\n+                except KeyError:\n+                    pass \n+                try:\n+                    del record[\"password\"]\n+                except KeyError:\n+                    pass \n+                try:\n+                    del record['message']\n+                except:\n+                    pass\n+                try:\n+                    del record['html']\n+                except: \n+                    pass\n             return [dict(record) async for record in self.services.channel.query(args[0])]\n \n         async def __call__(self, data):"}
{"repo": ".", "date": "2025-02-20", "line": "refactor: Reduced gunicorn workers in compose file", "commit": "a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7", "diff": "commit a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 20 23:24:44 2025 +0000\n\n    Chaned docker compose.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0a42856..17bcf22 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Added sound effects for mentions", "commit": "54920e1545ffc68e2f928d3d042f5f11080f0d41", "diff": "commit 54920e1545ffc68e2f928d3d042f5f11080f0d41\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 21 00:24:14 2025 +0100\n\n    Added sound.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex b6b468b..7ddc9e8 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -263,7 +263,10 @@ class NotificationAudio {\n         this.schedule = new Schedule(timeout);\n     }\n \n-    sounds = [\"/audio/soundfx.d_beep3.mp3\"];\n+    sounds = [\n+        \"/audio/soundfx.d_beep3.mp3\",\n+        \"/audio/mention1.wav\"\n+    ];\n \n     play(soundIndex = 0) {\n         this.schedule.delay(() => {\ndiff --git a/src/snek/static/audio/mention1.wav b/src/snek/static/audio/mention1.wav\nnew file mode 100644\nindex 0000000..640937d\nBinary files /dev/null and b/src/snek/static/audio/mention1.wav differ\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 910f601..4d50b0f 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -116,11 +116,20 @@\n \n     setInterval(updateTimes, 1000);\n \n+    function isMention(message){\n+        const mentionText = '@{{ user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    \n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) return;\n \n         if (data.username !== \"{{ user.username.value }}\") {\n+            if(isMention(data.message)){\n+                app.playSound(1);\n+            }else{\n             app.playSound(0);\n+            }\n         }\n         \n         const messagesContainer = document.querySelector(\".chat-messages\");\n@@ -128,10 +137,6 @@\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n         const message = document.createElement(\"div\");\n-        message.dataset.color = data.color;\n-        message.dataset.created_at = data.created_at;\n-        message.dataset.user_nick = data.user_nick;\n-        message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Implement distinct notification sounds for messages and mentions", "commit": "8ea41bb592b86e2f49b2f838e03006bc04472da5", "diff": "commit 8ea41bb592b86e2f49b2f838e03006bc04472da5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 21 00:41:22 2025 +0100\n\n    Addded notifs.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 7ddc9e8..b04e8fb 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -263,10 +263,12 @@ class NotificationAudio {\n         this.schedule = new Schedule(timeout);\n     }\n \n-    sounds = [\n-        \"/audio/soundfx.d_beep3.mp3\",\n-        \"/audio/mention1.wav\"\n-    ];\n+    sounds = {\n+        \"message\": \"/audio/soundfx.d_beep3.mp3\",\n+        \"mention\":  \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n+        \"messageOtherChannel\":  \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n+        \"ping\":  \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n+    }\n \n     play(soundIndex = 0) {\n         this.schedule.delay(() => {\ndiff --git a/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav b/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav\nnew file mode 100644\nindex 0000000..640937d\nBinary files /dev/null and b/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav differ\ndiff --git a/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav b/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav\nnew file mode 100644\nindex 0000000..ff5347c\nBinary files /dev/null and b/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav differ\ndiff --git a/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav b/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav\nnew file mode 100644\nindex 0000000..0334845\nBinary files /dev/null and b/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav differ\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4d50b0f..fb935c8 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -116,19 +116,32 @@\n \n     setInterval(updateTimes, 1000);\n \n-    function isMention(message){\n+    function isMentionToMe(message){\n         const mentionText = '@{{ user.username.value }}';\n         return message.toLowerCase().includes(mentionText);\n     }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message){\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n     \n     app.addEventListener(\"channel-message\", (data) => {\n-        if (data.channel_uid !== channelUid) return;\n-\n+        if (data.channel_uid !== channelUid) {\n+            if(!isMentionForSomeoneElse(data.message)){\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+            \n+            return;\n+        }\n         if (data.username !== \"{{ user.username.value }}\") {\n-            if(isMention(data.message)){\n-                app.playSound(1);\n-            }else{\n-            app.playSound(0);\n+            if(isMentionToMe(data.message)){\n+                app.playSound(\"mention\");\n+            }else if (!isMentionForSomeoneElse(data.message)){\n+                app.playSound(\"message\");\n             }\n         }"}
{"repo": ".", "date": "2025-02-22", "line": "refactor: Moved sidebar channels to separate template and added channel notification", "commit": "fbe95d6631dfac2edc4c8600922020be4e15eccb", "diff": "commit fbe95d6631dfac2edc4c8600922020be4e15eccb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 22 01:21:44 2025 +0100\n\n    Side bar fix.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 88a6335..185d22e 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -33,14 +33,7 @@\n   </header>\n   <main>\n     {% block sidebar %}\n-    <aside class=\"sidebar\">\n-      <h2 class=\"no-select\">Channels</h2>\n-      <ul>\n-        {% for channel in channels %}\n-        <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\n-        {% endfor %}\n-      </ul>\n-    </aside>\n+    {% include \"sidebar_channels.html\" %}\n     {% endblock %}\n     {% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex fb935c8..bdcddae 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -132,6 +132,7 @@\n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n+                channelSidebar.notify(data);\n                 app.playSound(\"messageOtherChannel\");\n             }\n             \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex dda589f..5a3b264 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -52,7 +52,7 @@ class WebView(BaseView):\n             other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n-                item[\"uid\"] = other_user[\"uid\"]\n+                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             else:\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]"}
{"repo": ".", "date": "2025-02-22", "line": "feat: Added channel sidebar with message counts and highlighting", "commit": "076fbb30fb51ecfb5b15d394c760f55dac26e1c1", "diff": "commit 076fbb30fb51ecfb5b15d394c760f55dac26e1c1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 22 01:21:57 2025 +0100\n\n    Added sidebar stuff.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nnew file mode 100644\nindex 0000000..ed16ffd\n--- /dev/null\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -0,0 +1,64 @@\n+<style>\n+  .channel-list-item-highlight {\n+  }\n+</style>\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2 class=\"no-select\">Channels</h2>\n+    <ul>\n+      {% for channel in channels %}\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      {% endfor %}\n+    </ul>\n+  </aside>\n+  <script>\n+    class ChannelSidebar {\n+        constructor(el){\n+          this.el = el \n+        }\n+        get channelNodes() {\n+          return this.el.querySelectorAll(\"li\")\n+        }\n+        channelListItemByUid(channelUid){\n+          const id = \"channel-list-item-\" + channelUid;\n+          console.error(id)\n+          return document.getElementById(id)\n+        }\n+        incrementMessageCount(channelUid){\n+          let messageCount = this.getMessageCount(channelUid)\n+          messageCount++;\n+          this.setMessageCount(channelUid, messageCount)\n+        }\n+        getMessageCount(channelUid){\n+          const li = this.channelListItemByUid(channelUid);\n+          if(li){\n+            let messageCount = li.dataset['messageCount']\n+            if(!messageCount){\n+              return 0\n+            }\n+            return new Number(messageCount)\n+          }\n+        }\n+        setMessageCount(channelUid, count){\n+          const li = this.channelListItemByUid(channelUid);\n+          if(li){\n+            li.dataset.messageCount = new String(count)\n+            li.dataset['messageCount'] = count\n+            li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+          }\n+        }\n+        notify(message){\n+          const li = this.channelListItemByUid(message.channel_uid);\n+          if(li){\n+            this.incrementMessageCount(message.channel_uid)\n+            li.classList.add(\"channel-list-item-highlight\")\n+            li.querySelectorAll(\"a\").forEach(el=>{\n+              el.style.color = message.color\n+            })\n+          }\n+        }\n+\n+    }\n+    const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n+\n+  </script>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Added avatar generation and display\n\nThis commit introduces avatar generation using the `multiavatar` library.\n\nChanges:\n\n- Added `multiavatar` as a dependency in `pyproject.toml`.\n- Created a new `AvatarView` to serve avatar images.\n- Updated `message.html` to display avatar images instead of initials.\n- Added a new route `/avatar/{uid}.svg` in `src/snek/app.py`.\n- Added caching headers to avatar and drive item responses.\n", "commit": "5af4e5754b6902ae13c798d9793281d62b684590", "diff": "commit 5af4e5754b6902ae13c798d9793281d62b684590\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:22:36 2025 +0100\n\n    Update avatar.\n\ndiff --git a/Makefile b/Makefile\nindex f153fc1..e3febf7 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -5,13 +5,15 @@ GUNICORN=./.venv/bin/gunicorn\n GUNICORN_WORKERS = 1\n PORT = 8081\n \n+python:\n+\t$(PYTHON)\n \n+run:\n+\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n+\t\n install:\n \tpython3 -m venv .venv \n \t$(PIP) install -e .\n \n \n \n-run:\n-\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n-\t\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 2d5b6d9..e1075c9 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -28,6 +28,7 @@ dependencies = [\n     \"asyncssh\",\n     \"emoji\",\n     \"aiofiles\",\n-    \"PyJWT\"\n+    \"PyJWT\",\n+    \"multiavatar\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3a61c2b..99f23e7 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -33,8 +33,10 @@ from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n+from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n \n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -101,6 +103,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n         self.router.add_view(\"/search-user.json\", SearchUserView)\n+        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 2773f0a..e8afc31 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nnew file mode 100644\nindex 0000000..065ff6a\n--- /dev/null\n+++ b/src/snek/view/avatar.py\n@@ -0,0 +1,39 @@\n+\n+\n+\n+from multiavatar import multiavatar\n+\n+from aiohttp import web\n+from snek.system.view import BaseView\n+\n+class AvatarView(BaseView):\n+    login_required = True\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        avatar = multiavatar.multiavatar(uid,None, None)\n+        response = web.Response(text=avatar, content_type='image/svg+xml')\n+        response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n+        return response\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 05ffcaa..a80ec72 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -23,7 +23,9 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-        return web.FileResponse(drive_item[\"path\"])\n+        response = web.FileResponse(drive_item[\"path\"])\n+        response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n+        return response\n \n     async def post(self):\n         reader = await self.request.multipart()"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatar UIDs when requested", "commit": "e280e8776457605bbb5548fe9de5328b7b04bb8a", "diff": "commit e280e8776457605bbb5548fe9de5328b7b04bb8a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:40:18 2025 +0100\n\n    Update avatar.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 065ff6a..8281df1 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -24,7 +24,7 @@\n from multiavatar import multiavatar\n-\n+import uuid\n from aiohttp import web\n from snek.system.view import BaseView\n \n@@ -33,6 +33,8 @@ class AvatarView(BaseView):\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n+        if uid == \"unique\":\n+            uid = str(uuid.uuid4())\n         avatar = multiavatar.multiavatar(uid,None, None)\n         response = web.Response(text=avatar, content_type='image/svg+xml')\n         response.headers['Cache-Control'] = f'public, max-age={1337*42}'"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatars", "commit": "162cfe394558a085894a11cea57b193d6108b90e", "diff": "commit 162cfe394558a085894a11cea57b193d6108b90e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:46:48 2025 +0100\n\n    Update avatar.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 8281df1..54dbec0 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -35,7 +35,7 @@ class AvatarView(BaseView):\n         uid = self.request.match_info.get(\"uid\")\n         if uid == \"unique\":\n             uid = str(uuid.uuid4())\n-        avatar = multiavatar.multiavatar(uid,None, None)\n+        avatar = multiavatar.multiavatar(uid, True, None)\n         response = web.Response(text=avatar, content_type='image/svg+xml')\n         response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n         return response"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Updated welcome message and registration buttons", "commit": "da1be6301c79cf76383e6568f91ee23bdf5119f6", "diff": "commit da1be6301c79cf76383e6568f91ee23bdf5119f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 06:21:48 2025 +0100\n\n    Text\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex c6d57df..1894bc4 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -11,12 +11,11 @@\n <body>\n   <div class=\"registration-container\">\n     <h1>Snek</h1>\n+    <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n+    So Snek came through, lean and optimized.</p>\n     <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n-    <span style=\"padding:10px;\">Or</span>\n+    <span style=\"padding:10px;\">OR</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n-    <a href=\"/about.html\">Design choices</a>\n-    <a href=\"/web.html\">App preview</a>\n-    <a href=\"/docs/docs/\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-02-28", "line": "fix: Disable login requirement for avatar view", "commit": "66b85d146abac25df83edc1975db209b9d43fae7", "diff": "commit 66b85d146abac25df83edc1975db209b9d43fae7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 28 15:42:52 2025 +0100\n\n    Non required avatar stuff.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 54dbec0..cbd973c 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -29,7 +29,7 @@ from aiohttp import web\n from snek.system.view import BaseView\n \n class AvatarView(BaseView):\n-    login_required = True\n+    login_required = False\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add last_message_on to ChannelModel and update on message send", "commit": "4620ebb800b5dd848ec28713f1afa20416698922", "diff": "commit 4620ebb800b5dd848ec28713f1afa20416698922\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 2 14:49:38 2025 +0100\n\n    Channel check.\n\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex d664087..8a40ced 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -9,3 +9,4 @@ class ChannelModel(BaseModel):\n     is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n     is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n+    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 02a4bd5..5eacbeb 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,12 +1,16 @@\n \n \n \n+from snek.system.model import now\n from snek.system.service import BaseService\n \n \n class ChatService(BaseService):\n \n     async def send(self,user_uid, channel_uid, message):\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        if not channel:\n+            raise Exception(\"Channel not found.\")\n         channel_message = await self.services.channel_message.create(\n             channel_uid, \n             user_uid, \n@@ -28,4 +32,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n+        channel['last_message_on'] = now()\n+        await self.services.channel.save(channel)\n         return sent_to_count\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add index creation with error handling", "commit": "e469e27abfedc0b08b483e0715b4dc9b16240c5e", "diff": "commit e469e27abfedc0b08b483e0715b4dc9b16240c5e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 2 14:59:25 2025 +0100\n\n    Optinal indexes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 99f23e7..6d9346d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -67,13 +67,17 @@ class Application(BaseApplication):\n         self.setup_router()\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n-        if not self.db[\"user\"].has_index(\"username\"):\n-            self.db[\"user\"].create_index(\"username\", unique=True)\n-        if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n-            self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n-        if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n-            self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n-    \n+\n+        try:\n+            if not self.db[\"user\"].has_index(\"username\"):\n+                self.db[\"user\"].create_index(\"username\", unique=True)\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+        except:\n+            pass \n+        \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)"}
{"repo": ".", "date": "2025-03-03", "line": "fix: Corrected link and upload display formatting", "commit": "45e3239cc06cdab0a8e5c1c1ef56593f65e750ea", "diff": "commit 45e3239cc06cdab0a8e5c1c1ef56593f65e750ea\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 3 00:35:08 2025 +0100\n\n    Upload changes.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 22d007d..0e71b80 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -73,7 +73,7 @@ def set_link_target_blank(text):\n         element.attrs['target'] = '_blank'\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n-        element.attrs['href'] = element.attrs['href'].strip(\".\")\n+        element.attrs['href'] = element.attrs['href'].strip(\".\").strip(\",\")\n \n     return str(soup)\n \ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex a80ec72..f9bad33 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -77,7 +77,7 @@ class UploadView(BaseView):\n             await self.services.drive_item.save(drive_item)\n             response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            response = \"[url](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            response = \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added push notifications to service worker", "commit": "c3c94461c295ef4c6219051369472f983267437c", "diff": "commit c3c94461c295ef4c6219051369472f983267437c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:19:24 2025 +0100\n\n    Update service worker.\n\ndiff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js\nindex dfdc493..5881455 100644\n--- a/src/snek/static/service-worker.js\n+++ b/src/snek/static/service-worker.js\n@@ -1,3 +1,37 @@\n+async function requestNotificationPermission() {\n+  const permission = await Notification.requestPermission();\n+  return permission === 'granted';\n+}\n+\n+async function subscribeUser() {\n+  const registration = await navigator.serviceWorker.register('/service-worker.js');\n+  \n+  const subscription = await registration.pushManager.subscribe({\n+    userVisibleOnly: true,\n+    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)\n+  });\n+\n+  await fetch('/subscribe', {\n+    method: 'POST',\n+    body: JSON.stringify(subscription),\n+    headers: {\n+      'Content-Type': 'application/json'\n+    }\n+  });\n+}\n+\n+self.addEventListener('push', event => {\n+  const data = event.data.json();\n+  self.registration.showNotification(data.title, {\n+    body: data.message,\n+    icon: data.icon\n+  });\n+});\n+\n self.addEventListener(\"install\", (event) => {\n   console.log(\"Service worker installed\");\n });\n@@ -27,4 +61,4 @@ self.addEventListener(\"notificationclick\", (event) => {\n     event.notification.close();\n     event.waitUntil(clients.openWindow(\n-});"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement notification read functionality and unread stats", "commit": "578c182f2707a5f5b7c93f421e2035f7271aa60c", "diff": "commit 578c182f2707a5f5b7c93f421e2035f7271aa60c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:51:25 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 3815c60..27c65f4 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,9 +1,17 @@\n from snek.system.service import BaseService\n-\n+from snek.system.model import now\n \n class NotificationService(BaseService):\n     mapper_name = \"notification\"\n \n+    async def mark_as_read(self, user_uid, channel_message_uid):\n+        model = await self.get(user_uid, object_uid=channel_message_uid)\n+        model['read_at'] = now()\n+        await self.save(model)\n+\n+    async def get_unread_stats(self,user_uid):\n+        records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))\n+\n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n         model[\"object_uid\"] = object_uid\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d98e53b..c197f24 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -39,6 +39,11 @@ class RPCView(BaseView):\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n+        async def mark_as_read(self, message_uid):\n+            self._require_login()\n+            await self.services.notification.mark_as_read(self.user_uid, message_uid)\n+            return True\n+\n         async def login(self, username, password):\n             success = await self.services.user.validate_login(username, password)\n             if not success:\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 5a3b264..8fd5ddc 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,6 +46,9 @@ class WebView(BaseView):\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n         )]\n+        for message in messages:\n+            await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n+\n         channels = []\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n             item = {}"}
{"repo": ".", "date": "2025-03-05", "line": "fix: Handle missing notification model in mark_as_read", "commit": "580ec5ab0d57a542ab38b25a6c508804d5bcfa21", "diff": "commit 580ec5ab0d57a542ab38b25a6c508804d5bcfa21\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:52:18 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 27c65f4..6db762c 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -6,9 +6,12 @@ class NotificationService(BaseService):\n \n     async def mark_as_read(self, user_uid, channel_message_uid):\n         model = await self.get(user_uid, object_uid=channel_message_uid)\n+        if not model:\n+            return False \n         model['read_at'] = now()\n         await self.save(model)\n-\n+        return True \n+    \n     async def get_unread_stats(self,user_uid):\n         records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add new_count to ChannelMember and increment on notification", "commit": "84d7b11f24b37cdc41ce9d5bb24be4080af14be9", "diff": "commit 84d7b11f24b37cdc41ce9d5bb24be4080af14be9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:59:59 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex d199498..65ba3e4 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -13,3 +13,4 @@ class ChannelMemberModel(BaseModel):\n     )\n     is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n     is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n+    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6db762c..ad0acbe 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,6 +36,8 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n+            channel_member['new_count'] += 1\n+            await self.services.channel_member.save(channel_member)\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display new notification count in RPC view", "commit": "d7851e645785fd707a7c7fdc5b6fff036e0c80f7", "diff": "commit d7851e645785fd707a7c7fdc5b6fff036e0c80f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:02:02 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex c197f24..55accc7 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -94,6 +94,7 @@ class RPCView(BaseView):\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n                     \"tag\": channel[\"tag\"],\n+                    \"new_count\": subscription[\"new_count\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n                     \"is_read_only\": subscription[\"is_read_only\"]\n                 })"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "e1324e99bf06018a804a1a3dcc83f96cde04b1af", "diff": "commit e1324e99bf06018a804a1a3dcc83f96cde04b1af\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:05:34 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 5eacbeb..54e60dc 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,7 +17,10 @@ class ChatService(BaseService):\n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        \n+        if not channel_message['new_count']:\n+            channel_message['new_count'] = 0\n+        channel_message['new_count'] += 1\n+        await self.services.channel_message.save(channel_message)\n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex ad0acbe..6db762c 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,8 +36,6 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            channel_member['new_count'] += 1\n-            await self.services.channel_member.save(channel_member)\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count in chat and notification services", "commit": "afbf53938bd59e1e03dfc011063b27134dd0c054", "diff": "commit afbf53938bd59e1e03dfc011063b27134dd0c054\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:09:54 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 54e60dc..17c677b 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,10 +17,8 @@ class ChatService(BaseService):\n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        if not channel_message['new_count']:\n-            channel_message['new_count'] = 0\n-        channel_message['new_count'] += 1\n-        await self.services.channel_message.save(channel_message)\n+        \n+        \n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6db762c..a703f23 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,6 +36,11 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n+            if not channel_member['new_count']:\n+                channel_member['new_count'] = 0\n+            channel_member['new_count'] += 1\n+            await self.services.channel_member.save(channel_member)\n+            \n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added new_count to notifications", "commit": "8d3d7327d777ae3150ecaa237e137f8221310dfa", "diff": "commit 8d3d7327d777ae3150ecaa237e137f8221310dfa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:17:43 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a703f23..f5917e7 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -21,6 +21,7 @@ class NotificationService(BaseService):\n         model[\"object_type\"] = object_type\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n+        model['new_count'] = 1337\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")\n@@ -40,7 +41,7 @@ class NotificationService(BaseService):\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n             await self.services.channel_member.save(channel_member)\n-            \n+\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Remove unused new_count field from notification model", "commit": "4df6055566d61c769ae1759d81900d138093136e", "diff": "commit 4df6055566d61c769ae1759d81900d138093136e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:18:06 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f5917e7..399a0a1 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -21,7 +21,6 @@ class NotificationService(BaseService):\n         model[\"object_type\"] = object_type\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n-        model['new_count'] = 1337\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment notification count and add debug print", "commit": "dd11c8da5acc5ed97a278a705e7282edc7d50bc5", "diff": "commit dd11c8da5acc5ed97a278a705e7282edc7d50bc5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:18:48 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 399a0a1..5a301a8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,6 +39,7 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n+            print(\"INSERTED!!\",flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Log model records during upsert", "commit": "8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d", "diff": "commit 8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:24:32 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 5a301a8..399a0a1 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,7 +39,6 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n-            print(\"INSERTED!!\",flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex d7e4163..e57b0fa 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -49,6 +49,7 @@ class BaseMapper:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n         model.updated_at.update()\n+        print(model.record,flush=True)\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Allow channel members to be created", "commit": "30fe0bfae7f2bc9badcb16f734655e661fe976ad", "diff": "commit 30fe0bfae7f2bc9badcb16f734655e661fe976ad\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:37:44 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex f34f08b..191a063 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -5,6 +5,7 @@ class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n+\n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d65f947..9feb759 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -61,7 +61,7 @@ class BaseService:\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n-            yield model\n+            yield await self.get(uid=model[\"uid\"])\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Return model directly from query", "commit": "edb35b57070a5659e8491a967880d816f2d07697", "diff": "commit edb35b57070a5659e8491a967880d816f2d07697\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:40:53 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 9feb759..d65f947 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -61,7 +61,7 @@ class BaseService:\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n-            yield await self.get(uid=model[\"uid\"])\n+            yield model\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display username when inserting notification", "commit": "0613f6f54de9247c17b4bae924197ffb4cbd2966", "diff": "commit 0613f6f54de9247c17b4bae924197ffb4cbd2966\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:48:27 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 399a0a1..f8a87c8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,6 +39,9 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n+            \n+            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Improve notification visibility by initializing new_count", "commit": "1807cff67d3a4306f122d7df4436cc88137f299c", "diff": "commit 1807cff67d3a4306f122d7df4436cc88137f299c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:51:50 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f8a87c8..6041901 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -32,9 +32,9 @@ class NotificationService(BaseService):\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n         ):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Implemented threads view with basic message display", "commit": "5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0", "diff": "commit 5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 7 20:58:53 2025 +0100\n\n    New stuff.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nnew file mode 100644\nindex 0000000..2d7d01b\n--- /dev/null\n+++ b/src/snek/templates/threads.html\n@@ -0,0 +1,153 @@\n+{% extends \"app.html\" %}\n+\n+{% block main %}\n+<section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">\n+        <h2>?</h2>\n+    </div>\n+    <div class=\"chat-messages\">\n+        {% for thread in threads %}\n+        {% autoescape false %}\n+        <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n+            data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n+            class=\"message\">\n+            <div class=\"avatar\" style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+                    src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n+            <div class=\"message-content\">\n+                <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n+                    endautoescape %}</div>\n+                <div class=\"time no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n+            </div>\n+        </div>\n+\n+        {% endautoescape %}\n+        {% endfor %}\n+    </div>\n+    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n+</section>\n+\n+<script>\n+\n+    function updateTimes() {\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+            time.innerText = app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n+\n+    function isElementVisible(element) {\n+        const rect = element.getBoundingClientRect();\n+        return (\n+            rect.top >= 0 &&\n+            rect.left >= 0 &&\n+            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+        );\n+    }\n+\n+    const messagesContainer = document.querySelector(\".chat-messages\");\n+\n+    function isScrolledPastHalf() {\n+        let scrollTop = messagesContainer.scrollTop;\n+        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n+\n+        if (scrollTop < scrollableHeight / 2) {\n+            return true;\n+        }\n+        return false;\n+    }\n+\n+    let isLoadingExtra = false;\n+\n+    async function loadExtra() {\n+        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n+        if (isLoadingExtra) {\n+            return;\n+        }\n+        if (!isScrolledPastHalf()) {\n+            return;\n+        }\n+\n+        isLoadingExtra = true;\n+\n+        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n+\n+        messages.forEach((message) => {\n+            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+        })\n+        updateLayout(false);\n+\n+        isLoadingExtra = false;\n+    }\n+\n+    messagesContainer.addEventListener(\"scroll\", () => {\n+        loadExtra();\n+    });\n+\n+    function updateLayout(doScrollDown) {\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        updateTimes();\n+        let previousUser = null;\n+        document.querySelectorAll(\".message\").forEach((message) => {\n+            if (previousUser !== message.dataset.user_uid) {\n+                message.classList.add(\"switch-user\");\n+                previousUser = message.dataset.user_uid;\n+            } else {\n+                message.classList.remove(\"switch-user\");\n+            }\n+        });\n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        if (doScrollDown) {\n+            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        }\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    function isMentionToMe(message) {\n+        const mentionText = '@{{ user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message) {\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) {\n+            if (!isMentionForSomeoneElse(data.message)) {\n+                channelSidebar.notify(data);\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+\n+            return;\n+        }\n+        if (data.username !== \"{{ user.username.value }}\") {\n+            if (isMentionToMe(data.message)) {\n+                app.playSound(\"mention\");\n+            } else if (!isMentionForSomeoneElse(data.message)) {\n+                app.playSound(\"message\");\n+            }\n+        }\n+\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+\n+        const message = document.createElement(\"div\");\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n+        setTimeout(() => {\n+            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n+        }, 1000);\n+    });\n+\n+    initInputField(document.querySelector(\"textarea\"));\n+    updateLayout(true);\n+</script>\n+{% endblock %}\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nnew file mode 100644\nindex 0000000..d8f4359\n--- /dev/null\n+++ b/src/snek/view/threads.py\n@@ -0,0 +1,31 @@\n+from snek.system.view import BaseView\n+\n+class ThreadsView(BaseView):\n+\n+    async def get(self):\n+        threads = []\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        async for channel_member in user.get_channel_members():\n+            thread = {}\n+            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            thread[\"uid\"] = channel['uid']\n+            thread[\"name\"] = await channel_member.get_name()\n+            thread[\"new_count\"] = channel_member[\"new_count\"]\n+            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n+            thread['created_at'] = thread['last_message_on']\n+            last_message = await channel.get_last_message()\n+            if last_message:\n+                thread[\"last_message_text\"] = last_message[\"message\"]\n+                thread['last_message_user_uid'] = last_message[\"user_uid\"]\n+                user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n+                thread['last_message_user_nick'] = user_last_message[\"nick\"]\n+                thread['last_message_user_color'] = user_last_message['color']\n+            else:\n+                thread[\"last_message_text\"] = None \n+                thread['last_message_user_uid'] = None \n+                thread['last_message_user_nick'] = None     \n+                thread['last_message_user_color'] = None\n+            threads.append(thread)\n+\n+\n+        return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Added threads view and related model updates", "commit": "a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568", "diff": "commit a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 7 20:59:11 2025 +0100\n\n    New stuff.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6d9346d..e1f74e2 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,7 +1,9 @@\n import pathlib\n import asyncio\n \n-import logging \n+import logging\n+\n+from snek.view.threads import ThreadsView \n \n logging.basicConfig(level=logging.DEBUG)\n \n@@ -112,6 +114,7 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n+        self.router.add_view(\"/threads.html\", ThreadsView)\n \n         self.add_subapp(\n             \"/docs\",\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 8a40ced..0070948 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,3 +1,4 @@\n+from snek.model.channel_message import ChannelMessageModel\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -10,3 +11,11 @@ class ChannelModel(BaseModel):\n     is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n+\n+    async def get_last_message(self)->ChannelMessageModel:\n+        async for model in self.app.services.channel_message.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n+            return model \n+        return None\n+\n+    async def get_members(self):\n+        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 65ba3e4..5a8332e 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -14,3 +14,27 @@ class ChannelMemberModel(BaseModel):\n     is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n     is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n     new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n+\n+    async def get_user(self):\n+        return await self.app.services.user.get(uid=self['user_uid'])\n+    \n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self['channel_uid'])\n+\n+    async def get_name(self):\n+        if self[\"channel_uid\"] == \"dm\":\n+            user = await self.get_other_dm_user()\n+            return user['nick']\n+        channel = await self.get_channel()\n+        return channel['name']\n+\n+    async def get_other_dm_user(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+        \n+        async for model in self.app.services.channel_member.find(channel_uid=channel['uid']):\n+            if model[\"uid\"] != self['uid']:\n+                return await self.app.services.user.get(uid=model[\"user_uid\"])\n+        return await self.get_user()\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0bda0bc..4b84fdc 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,3 +1,4 @@\n+from snek.model.user import UserModel\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -6,3 +7,9 @@ class ChannelMessageModel(BaseModel):\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n+\n+    async def get_user(self)->UserModel:\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+    \n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 54b6f30..0621ecf 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -31,3 +31,7 @@ class UserModel(BaseModel):\n     password = ModelField(name=\"password\", required=True, min_length=1)\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n+\n+    async def get_channel_members(self):\n+        async for channel_member in self.app.services.channel_member.find(user_uid=self['uid'],is_banned=False,deleted_at=None):\n+            yield channel_member\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6041901..f8a87c8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -32,9 +32,9 @@ class NotificationService(BaseService):\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n         ):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex e57b0fa..e7415e1 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -20,7 +20,7 @@ class BaseMapper:\n         return self.app.db\n \n     async def new(self):\n-        return self.model_class(mapper=self)\n+        return self.model_class(mapper=self, app=self.app)\n \n     @property\n     def table(self):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex ba3fc45..1aef4ae 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -270,7 +270,8 @@ class BaseModel:\n         return self\n \n     def __init__(self, *args, **kwargs):\n-        self._mapper = None\n+        self._mapper = kwargs.get(\"mapper\")\n+        self.app = kwargs.get(\"app\")\n         self.fields = {}\n         for key in dir(self.__class__):\n             obj = getattr(self.__class__, key)\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 185d22e..60109de 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -26,7 +26,7 @@\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n-      <a class=\"no-select\" href=\"/web.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Prevent race condition when reconnecting socket", "commit": "e3afc1ba6e97378688027a60d6d98cc19a519a8c", "diff": "commit e3afc1ba6e97378688027a60d6d98cc19a519a8c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 03:51:54 2025 +0100\n\n    Fix socket issue.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex b04e8fb..f26919e 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -183,8 +183,9 @@ class Socket extends EventHandler {\n         const me = this \n         if (this.isConnected || this.isConnecting) {\n             return new Promise((resolve) => {\n+                if(me.isConnected)resolve(me)\n+                else if(me.isConnecting)\n                 me.connectPromises.push(resolve);\n-                if (!me.isConnecting) resolve(me);\n             });\n         }\n         this.isConnecting = true;"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread display with consistent avatar styling", "commit": "37f6725f2f7e36ec03416f191c9d16cd864991ea", "diff": "commit 37f6725f2f7e36ec03416f191c9d16cd864991ea\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:07:21 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 2d7d01b..8131224 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div class=\"avatar\" style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Display user avatars in threads", "commit": "095be5892db198d0a6356c8700ed0c038e419a29", "diff": "commit 095be5892db198d0a6356c8700ed0c038e419a29\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:10:05 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 8131224..bb54f4d 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div class=\"avatar\" style=\"display: block !important; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread avatar visibility", "commit": "9292e3b8f3b64084d6bcc0b13dd42d015f4799d9", "diff": "commit 9292e3b8f3b64084d6bcc0b13dd42d015f4799d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:11:34 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex bb54f4d..4141e23 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div class=\"avatar\" style=\"display: block !important; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve thread display and opacity", "commit": "24260f9c371ab2d989441e391f513f6460eaa1ec", "diff": "commit 24260f9c371ab2d989441e391f513f6460eaa1ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:24:39 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 4141e23..8ec781f 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -2,9 +2,6 @@\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\">\n-        <h2>?</h2>\n-    </div>\n     <div class=\"chat-messages\">\n         {% for thread in threads %}\n         {% autoescape false %}\n@@ -14,10 +11,10 @@\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n-                <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"author\" style=\"opacity: 1; color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n                 <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n                     endautoescape %}</div>\n-                <div class=\"time no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n+                <div class=\"time opacity: 1; no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n             </div>\n         </div>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Unified styling and thread display in chat area", "commit": "1b72063a5b972dd726c647b7397f0ced16bd66c2", "diff": "commit 1b72063a5b972dd726c647b7397f0ced16bd66c2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 07:22:14 2025 +0100\n\n    Channel list.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e42784b..4405785 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -102,7 +102,7 @@ a {\n   overflow-y: auto;\n }\n \n-.chat-messages {\n+.chat-messages, .threads {\n   flex: 1;\n   overflow-y: scroll;\n   scrollbar-width: none;\n@@ -130,7 +130,7 @@ a {\n   display: none;\n }\n \n-.chat-messages .message {\n+.chat-messages .message, .threads .thread {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0;\n@@ -138,7 +138,7 @@ a {\n   border-radius: 8px;\n }\n \n-.chat-messages .message .avatar {\n+.chat-messages .message .avatar, .threads .thread .avatar {\n   width: 40px;\n   height: 40px;\n   border-radius: 50%;\n@@ -152,11 +152,11 @@ a {\n   margin-right: 15px;\n }\n \n-.chat-messages .message .message-content {\n+.chat-messages .message .message-content, .threads .thread .message-content {\n   flex: 1;\n }\n \n-.chat-messages .message .message-content .author {\n+.chat-messages .message .message-content .author, .threads .thread .message-content .author {\n   font-weight: bold;\n   margin-bottom: 3px;\n@@ -172,7 +172,7 @@ word-break: break-word;\n     overflow-wrap: break-word;\n     hyphens: auto;\n   }\n-.chat-messages .message .message-content .text {\n+.chat-messages .message .message-content .text, .threads .thread .message-content .text {\n   margin-bottom: 5px;\n   word-break: break-word;\n@@ -191,7 +191,7 @@ hyphens: auto;\n     }\n }\n \n-.chat-messages .message .message-content .time {\n+.chat-messages .message .message-content .time, .threads .thread .message-content .time {\n   font-size: 0.8em;\n }\n@@ -294,7 +294,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n-.chat-messages {\n+.chat-messages, .threads {\n   scrollbar-width: none;\n }\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 8ec781f..b982914 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -2,12 +2,12 @@\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-messages\">\n+    <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n-            class=\"message\">\n+            class=\"thread\">\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n@@ -32,71 +32,11 @@\n         });\n     }\n \n-    function isElementVisible(element) {\n-        const rect = element.getBoundingClientRect();\n-        return (\n-            rect.top >= 0 &&\n-            rect.left >= 0 &&\n-            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n-            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n-        );\n-    }\n-\n-    const messagesContainer = document.querySelector(\".chat-messages\");\n-\n-    function isScrolledPastHalf() {\n-        let scrollTop = messagesContainer.scrollTop;\n-        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n-\n-        if (scrollTop < scrollableHeight / 2) {\n-            return true;\n-        }\n-        return false;\n-    }\n \n-    let isLoadingExtra = false;\n-\n-    async function loadExtra() {\n-        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if (isLoadingExtra) {\n-            return;\n-        }\n-        if (!isScrolledPastHalf()) {\n-            return;\n-        }\n-\n-        isLoadingExtra = true;\n-\n-        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n-\n-        messages.forEach((message) => {\n-            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-        })\n-        updateLayout(false);\n-\n-        isLoadingExtra = false;\n-    }\n-\n-    messagesContainer.addEventListener(\"scroll\", () => {\n-        loadExtra();\n-    });\n+    const threadsContainer = document.querySelector(\".chat-threads\");\n \n     function updateLayout(doScrollDown) {\n-        const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n-        let previousUser = null;\n-        document.querySelectorAll(\".message\").forEach((message) => {\n-            if (previousUser !== message.dataset.user_uid) {\n-                message.classList.add(\"switch-user\");\n-                previousUser = message.dataset.user_uid;\n-            } else {\n-                message.classList.remove(\"switch-user\");\n-            }\n-        });\n-        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if (doScrollDown) {\n-            lastMessage.scrollIntoView({ inline: \"nearest\" });\n-        }\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -131,20 +71,17 @@\n             }\n         }\n \n-        const messagesContainer = document.querySelector(\".chat-messages\");\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+        const threadsContainer = document.querySelector(\".chat-threads\");\n \n         const message = document.createElement(\"div\");\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n-        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n+        document.querySelector(\".chat-threads\").appendChild(message.firstChild);\n+        updateLayout();\n         setTimeout(() => {\n-            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n+            updateLayout()\n         }, 1000);\n     });\n \n-    initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved thread display and DM channel naming\n\nfix: Corrected DM channel retrieval in ChannelMemberService", "commit": "5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a", "diff": "commit 5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 08:25:49 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 5a8332e..9689fa5 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -22,11 +22,11 @@ class ChannelMemberModel(BaseModel):\n         return await self.app.services.channel.get(uid=self['channel_uid'])\n \n     async def get_name(self):\n-        if self[\"channel_uid\"] == \"dm\":\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] == \"dm\":\n             user = await self.get_other_dm_user()\n             return user['nick']\n-        channel = await self.get_channel()\n-        return channel['name']\n+        return channel['name'] or self['label']\n \n     async def get_other_dm_user(self):\n         channel = await self.get_channel()\n@@ -37,4 +37,4 @@ class ChannelMemberModel(BaseModel):\n             if model[\"uid\"] != self['uid']:\n                 return await self.app.services.user.get(uid=model[\"user_uid\"])\n         return await self.get_user()\n-        \n\\ No newline at end of file\n+        \ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 191a063..42415d1 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -36,7 +36,11 @@ class ChannelMemberService(BaseService):\n     async def get_dm(self,from_user, to_user):\n         async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n             return model\n-        return None \n+        if not from_user == to_user:\n+            return None \n+        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+           \n+            return model \n     \n     async def get_other_dm_user(self, channel_uid, user_uid):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex b982914..73c256a 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -5,18 +5,18 @@\n     <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}\n-        <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n-            data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n+        <a href=\"/channel/{{thread.uid}}.html\" style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n+            data-name=\"{{thread.name}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{thread.user_uid}}\"\n             class=\"thread\">\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n-                <div class=\"author\" style=\"opacity: 1; color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"author\" style=\"opacity: 1; color: {{thread.name_color}};\">{{thread.name}}</div>\n                 <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n                     endautoescape %}</div>\n                 <div class=\"time opacity: 1; no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n             </div>\n-        </div>\n+        </a>\n \n         {% endautoescape %}\n         {% endfor %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex bdcddae..2c48cb4 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -3,7 +3,7 @@\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n     <div class=\"chat-header\">\n-        <h2>{{ channel.label.value }}</h2>\n+        <h2>{{ name }}</h2>\n     </div>\n     <div class=\"chat-messages\">\n         {% for message in messages %}\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex d8f4359..772685f 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -8,23 +8,24 @@ class ThreadsView(BaseView):\n         async for channel_member in user.get_channel_members():\n             thread = {}\n             channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            last_message = await channel.get_last_message()\n+            if not last_message:\n+                continue\n+\n             thread[\"uid\"] = channel['uid']\n             thread[\"name\"] = await channel_member.get_name()\n             thread[\"new_count\"] = channel_member[\"new_count\"]\n             thread[\"last_message_on\"] = channel[\"last_message_on\"]\n             thread['created_at'] = thread['last_message_on']\n-            last_message = await channel.get_last_message()\n-            if last_message:\n-                thread[\"last_message_text\"] = last_message[\"message\"]\n-                thread['last_message_user_uid'] = last_message[\"user_uid\"]\n-                user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n-                thread['last_message_user_nick'] = user_last_message[\"nick\"]\n-                thread['last_message_user_color'] = user_last_message['color']\n-            else:\n-                thread[\"last_message_text\"] = None \n-                thread['last_message_user_uid'] = None \n-                thread['last_message_user_nick'] = None     \n-                thread['last_message_user_color'] = None\n+\n+            \n+            thread[\"last_message_text\"] = last_message[\"message\"]\n+            thread['last_message_user_uid'] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n+            if channel['tag'] == \"dm\":\n+                thread['name_color'] = user_last_message['color']\n+            thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n \n \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 8fd5ddc..3ae7902 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -42,6 +42,11 @@ class WebView(BaseView):\n                     return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n+        \n+        channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n@@ -60,4 +65,6 @@ class WebView(BaseView):\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n-        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file\n+\n+        name = await channel_member.get_name()\n+        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Round follow-up messages corners", "commit": "98c2213a862b253f8f967f428b0b248bbe3a32f7", "diff": "commit 98c2213a862b253f8f967f428b0b248bbe3a32f7\nAuthor: BordedDev <>\nDate:   Sat Mar 8 17:25:56 2025 +0100\n\n    Fix rounded corners on follow-up messages\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4405785..bcdc1d1 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,183 +1,189 @@\n * {\n-  margin: 0;\n-  box-sizing: border-box;\n+    margin: 0;\n+    box-sizing: border-box;\n }\n \n .gallery {\n-  padding: 50px;\n-  height: auto;\n-  overflow: auto;\n-  flex: 1;\n+    padding: 50px;\n+    height: auto;\n+    overflow: auto;\n+    flex: 1;\n }\n \n .gallery.tile, .tile {\n-  width: 100px;\n-  height: 100px;\n-  object-fit: cover;\n-  margin: 20px 10px 20px 0;\n-  border-radius: 5px;\n+    width: 100px;\n+    height: 100px;\n+    object-fit: cover;\n+    margin: 20px 10px 20px 0;\n+    border-radius: 5px;\n }\n \n body {\n-  font-family: Arial, sans-serif;\n-  line-height: 1.5;\n-  display: flex;\n-  flex-direction: column;\n-  height: 100vh;\n-  min-width: 100%;\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    height: 100vh;\n+    min-width: 100%;\n }\n \n main {\n-  display: flex;\n-  flex: 1;\n-  overflow: hidden;\n+    display: flex;\n+    flex: 1;\n+    overflow: hidden;\n }\n \n header {\n-  padding: 10px 20px;\n-  display: flex;\n-  justify-content: space-between;\n-  align-items: center;\n+    padding: 10px 20px;\n+    display: flex;\n+    justify-content: space-between;\n+    align-items: center;\n }\n \n header .logo {\n-  font-size: 1.5em;\n-  font-weight: bold;\n+    font-size: 1.5em;\n+    font-weight: bold;\n }\n \n header nav a {\n-  text-decoration: none;\n-  margin-left: 15px;\n-  font-size: 1em;\n-  transition: color 0.3s;\n+    text-decoration: none;\n+    margin-left: 15px;\n+    font-size: 1em;\n+    transition: color 0.3s;\n }\n \n .no-select {\n-  -webkit-user-select: none;  \n-  -moz-user-select: none;     \n-  -ms-user-select: none;      \n-  user-select: none;          \n+    -webkit-user-select: none;\n+    -moz-user-select: none;\n+    -ms-user-select: none;\n+    user-select: none;\n }\n \n header nav a:hover {\n }\n \n a {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n+    font-weight: bold;\n+    margin-bottom: 3px;\n }\n \n .chat-area {\n-  flex: 1;\n-  display: flex;\n-  flex-direction: column;\n-  overflow: hidden;\n+    flex: 1;\n+    display: flex;\n+    flex-direction: column;\n+    overflow: hidden;\n }\n \n .chat-header {\n-  padding: 10px 20px;\n-  user-select: none;\n+    padding: 10px 20px;\n+    user-select: none;\n }\n \n .chat-header h2 {\n-  font-size: 1.2em;\n+    font-size: 1.2em;\n \n }\n \n .message-list {\n-  flex: 1;\n-  height: 200px;\n-  padding-bottom: 40px;\n-  overflow-y: auto;\n+    flex: 1;\n+    height: 200px;\n+    padding-bottom: 40px;\n+    overflow-y: auto;\n }\n \n .chat-messages, .threads {\n-  flex: 1;\n-  overflow-y: scroll;\n-  scrollbar-width: none;\n-  -ms-overflow-style: none;\n-  padding: 10px;\n-  height: 200px;\n+    flex: 1;\n+    overflow-y: scroll;\n+    scrollbar-width: none;\n+    -ms-overflow-style: none;\n+    padding: 10px;\n+    height: 200px;\n }\n+\n .container {\n-  flex: 1;\n-  padding: 10px;\n-  ul {\n-    list-style: none;\n-    margin: 0;\n-    padding: 0;\n-  }\n-  a {\n+    flex: 1;\n+    padding: 10px;\n+\n+    ul {\n+        list-style: none;\n+        margin: 0;\n+        padding: 0;\n+    }\n+\n+    a {\n         font-size: 20px;\n-      }\n- \n+    }\n+\n }\n \n .chat-messages::-webkit-scrollbar {\n-  display: none;\n+    display: none;\n }\n \n .chat-messages .message, .threads .thread {\n-  display: flex;\n-  align-items: flex-start;\n-  margin-bottom: 0;\n-  padding: 5px;\n-  border-radius: 8px;\n+    display: flex;\n+    align-items: flex-start;\n+    margin-bottom: 0;\n+    padding: 5px;\n+    border-radius: 8px;\n }\n \n .chat-messages .message .avatar, .threads .thread .avatar {\n-  width: 40px;\n-  height: 40px;\n-  border-radius: 50%;\n-  font-size: 1em;\n-  font-weight: bold;\n-  display: flex;\n-  justify-content: center;\n-  align-items: center;\n-  margin-right: 15px;\n+    width: 40px;\n+    height: 40px;\n+    border-radius: 50%;\n+    font-size: 1em;\n+    font-weight: bold;\n+    display: flex;\n+    justify-content: center;\n+    align-items: center;\n+    margin-right: 15px;\n }\n \n .chat-messages .message .message-content, .threads .thread .message-content {\n-  flex: 1;\n+    flex: 1;\n }\n \n .chat-messages .message .message-content .author, .threads .thread .message-content .author {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n+    font-weight: bold;\n+    margin-bottom: 3px;\n }\n+\n * {\n-word-break: break-word;\n-  overflow-wrap: break-word;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n     hyphens: auto;\n }\n+\n .highlight pre {\n     white-space: pre-wrap;\n     word-break: break-word;\n     overflow-wrap: break-word;\n     hyphens: auto;\n-  }\n+}\n+\n .chat-messages .message .message-content .text, .threads .thread .message-content .text {\n-  margin-bottom: 5px;\n-  word-break: break-word;\n-  overflow-wrap: break-word;\n-hyphens: auto; \n+    margin-bottom: 5px;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n }\n \n .message-content {\n@@ -187,151 +193,153 @@ hyphens: auto;\n .message-content {\n \n     img, video, iframe, div {\n-        max-width: 100%; \n+        max-width: 90%;\n+        border-radius: 20px;\n     }\n }\n \n .chat-messages .message .message-content .time, .threads .thread .message-content .time {\n-  font-size: 0.8em;\n+    font-size: 0.8em;\n }\n \n .chat-input {\n-  padding: 15px;\n-  display: flex;\n-  align-items: center;\n+    padding: 15px;\n+    display: flex;\n+    align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n-  flex: 1;\n-  color: white;\n-  border: none;\n-  padding: 10px;\n-  border-radius: 5px;\n-  resize: none;\n+    flex: 1;\n+    color: white;\n+    border: none;\n+    padding: 10px;\n+    border-radius: 5px;\n+    resize: none;\n }\n \n .chat-input upload-button {\n-  color: white;\n-  border: none;\n-  padding: 10px 15px;\n-  margin-left: 10px;\n-  border-radius: 5px;\n-  cursor: pointer;\n-  font-size: 1em;\n-  transition: background-color 0.3s;\n+    color: white;\n+    border: none;\n+    padding: 10px 15px;\n+    margin-left: 10px;\n+    border-radius: 5px;\n+    cursor: pointer;\n+    font-size: 1em;\n+    transition: background-color 0.3s;\n }\n \n .chat-input button:hover {\n }\n \n @media (max-width: 768px) {\n-  .sidebar {\n-    display: none;\n-  }\n+    .sidebar {\n+        display: none;\n+    }\n }\n \n .message {\n-  .text {\n-    max-width: 100%;\n-    word-wrap: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n-  }\n+    .text {\n+        max-width: 100%;\n+        word-wrap: break-word;\n+        overflow-wrap: break-word;\n+        hyphens: auto;\n+    }\n \n-  .avatar {\n-    opacity: 0;\n-  }\n+    .avatar {\n+        opacity: 0;\n+    }\n \n-  .author, .time {\n-    display: none;\n-  }\n+    .author, .time {\n+        display: none;\n+    }\n }\n \n .message.switch-user {\n-  .text img,iframe, video {\n-    max-width: 90%;\n-    border-radius: 20px;\n-  }\n-  \n-  .avatar {\n-    user-select: none;\n-    opacity: 1;\n-  }\n+    .text img, iframe, video {\n+        max-width: 90%;\n+        border-radius: 20px;\n+    }\n \n-  .author {\n-    display: block;\n-  }\n+    .avatar {\n+        user-select: none;\n+        opacity: 1;\n+    }\n+\n+    .author {\n+        display: block;\n+    }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child{ \n+.message:has(+ .message.switch-user), .message:last-child {\n     .time {\n-  display: block;\n-}\n+        display: block;\n+    }\n }\n \n ::-webkit-scrollbar {\n-    display:none;\n+    display: none;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n-  border-radius: 3px;\n+    border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n .chat-messages, .threads {\n-  scrollbar-width: none;\n+    scrollbar-width: none;\n }\n \n a {\n-    text-decoration:none\n+    text-decoration: none\n }\n+\n .sidebar {\n-  width: 250px;\n-  padding: 20px;\n-  overflow-y: auto;\n+    width: 250px;\n+    padding: 20px;\n+    overflow-y: auto;\n }\n \n .sidebar h2 {\n-  font-size: 1.2em;\n-  margin-bottom: 20px;\n+    font-size: 1.2em;\n+    margin-bottom: 20px;\n }\n \n .sidebar ul {\n-  list-style: none;\n+    list-style: none;\n }\n \n .sidebar ul li {\n-  margin-bottom: 15px;\n+    margin-bottom: 15px;\n }\n \n .sidebar ul li a {\n-  text-decoration: none;\n-  font-size: 1em;\n-  transition: color 0.3s;\n+    text-decoration: none;\n+    font-size: 1em;\n+    transition: color 0.3s;\n }\n \n .sidebar ul li a:hover {\n }"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved user search display with thread-like layout and real-time updates", "commit": "0a9b66d2f76a2c4418db7149b17729bf8a2dc811", "diff": "commit 0a9b66d2f76a2c4418db7149b17729bf8a2dc811\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 17:33:17 2025 +0100\n\n    Updated search.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex b6a0439..842b982 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -13,16 +13,90 @@\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n             <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>\n         </form>\n-        <ul>\n+        <div class=\"threads\">\n             {% for user in users %}\n-            <li>\n-                <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n-            </li>\n+        <a href=\"/channel/{{user.uid}}.html\" style=\"max-width:100%;\" data-uid=\"{{user.uid}}\" data-color=\"{{user.color}}\" data-channel_uid=\"{{user.uid}}\"\n+                                                                                                                         data-username=\"{{user.username}}\" data-nick=\"{{user.nick}}\" data-last_ping=\"{{user.last_ping}}\" \n+            class=\"thread\">\n+            <div class=\"avatar\" style=\"opacity: 1; background-color: {{user.color}}; color: black;\"><img\n+                    src=\"/avatar/{{user.uid}}.svg\" /></div>\n+            <div class=\"message-content\">\n+                <div class=\"author\" style=\"opacity: 1; color: {{user.color}};\">{{user.nick}}</div>\n+                <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{%raw %}{% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n+                    endautoescape %}</div>\n+                <div class=\"time opacity: 1; no-select\" data-created_at=\"{{user.last_ping}}\">{{user.last_ping}}</div>\n+            </div>\n+        </a>\n \n-            {% endfor %}\n-        </ul>\n \n \n+            {% endfor %}\n+    </div>\n+\n     </div>\n </section>\n-{% endblock %}\n\\ No newline at end of file\n+<script>\n+\n+    document.querySelector(\"[name=query]\").focus();\n+\n+    function updateTimes() {\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+            time.innerText = \"Last seen: \" + app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n+\n+\n+    const threadsContainer = document.querySelector(\".chat-threads\");\n+\n+    function updateLayout(doScrollDown) {\n+        updateTimes();\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    function isMentionToMe(message) {\n+        const mentionText = '@{{ current_user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message) {\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ current_user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) {\n+            if (!isMentionForSomeoneElse(data.message)) {\n+                channelSidebar.notify(data);\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+\n+            return;\n+        }\n+        if (data.username !== \"{{ current_user.username.value }}\") {\n+            if (isMentionToMe(data.message)) {\n+                app.playSound(\"mention\");\n+            } else if (!isMentionForSomeoneElse(data.message)) {\n+                app.playSound(\"message\");\n+            }\n+        }\n+\n+        const threadsContainer = document.querySelector(\".chat-threads\");\n+\n+        const message = document.createElement(\"div\");\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-threads\").appendChild(message.firstChild);\n+        updateLayout();\n+        setTimeout(() => {\n+            updateLayout()\n+        }, 1000);\n+    });\n+\n+    updateLayout(true);\n+</script>\n+\n+\n+{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex c28b883..347d3b2 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -40,14 +40,14 @@ class SearchUserView(BaseFormView):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n-            users = await self.app.services.user.search(query)\n+            users = [user.record for user in  await self.app.services.user.search(query)]\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-\n-        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or ''})\n+        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or '','current_user': current_user})\n \n     async def submit(self, form):\n         if await form.is_valid:\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Set default page titles and update login/register titles", "commit": "6ecd356cc08e17596ff6b5007c46def2bc17c851", "diff": "commit 6ecd356cc08e17596ff6b5007c46def2bc17c851\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:04:53 2025 +0100\n\n    Made improvement to page titles (mainly setting a default, login and regsiter)\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 36a012c..0785366 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -1,26 +1,25 @@\n <!DOCTYPE html>\n <html lang=\"en\">\n <head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>{% block title %}{% endblock %}</title>\n-  <script src=\"/app.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <style>{{ highlight_styles }}</style>\n-  <link rel=\"stylesheet\" href=\"/style.css\">\n-   <script src=\"/fancy-button.js\"></script>\n+    <meta charset=\"UTF-8\">\n+    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n+    <script src=\"/app.js\"></script>\n+    <script src=\"/message-list.js\"></script>\n+    <style>{{ highlight_styles }}</style>\n+    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <script src=\"/fancy-button.js\"></script>\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n-    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n-  \n+    <link rel=\"stylesheet\" href=\"/html-frame.css\">\n </head>\n <body>\n-    <header>\n-        {% block header %}\n-        {% endblock %}\n+<header>\n+    {% block header %}\n+    {% endblock %}\n \n-    </header>\n-    <main>\n+</header>\n+<main>\n     {% block main %}\n     {% endblock %}\n </main>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 0a6bcc1..0ffc379 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,7 +1,11 @@\n {% extends \"base.html\" %}\n \n+{% block title %}\n+    Login - Snek chat by Molodetz\n+{% endblock %}\n+\n {% block main %}\n-  <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-   <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+    <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex f8d1067..2b783a7 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,7 +1,11 @@\n {% extends \"base.html\" %}\n \n+{% block title %}\n+    Register - Snek chat by Molodetz\n+{% endblock %}\n+\n {% block main %}\n-<fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-  \n-  <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+\n+    <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improved form submission and change event handling", "commit": "804556b74d8caa5e3a79a03cd1a8d7870843b898", "diff": "commit 804556b74d8caa5e3a79a03cd1a8d7870843b898\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:09:14 2025 +0100\n\n    Fixed issues with auto complete not working correctly with form sync\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 730d70a..e71d817 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -62,7 +62,7 @@ class GenericField extends HTMLElement {\n \n   constructor() {\n     super();\n-    this.attachShadow({ mode: 'open' });\n+    this.attachShadow({mode: 'open'});\n     this.container = document.createElement('div');\n     this.styleElement = document.createElement('style');\n     this.styleElement.innerHTML = `\n@@ -165,18 +165,26 @@ class GenericField extends HTMLElement {\n       const me = this;\n       this.inputElement.addEventListener(\"keyup\", (e) => {\n         if (e.key === 'Enter') {\n+          const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n+          me.dispatchEvent(event);\n+\n           me.dispatchEvent(new Event(\"submit\"));\n         } else if (me.field.value !== e.target.value) {\n-          const event = new CustomEvent(\"change\", { detail: me, bubbles: true });\n+          const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n           me.dispatchEvent(event);\n         }\n       });\n \n       this.inputElement.addEventListener(\"click\", (e) => {\n-        const event = new CustomEvent(\"click\", { detail: me, bubbles: true });\n+        const event = new CustomEvent(\"click\", {detail: me, bubbles: true});\n         me.dispatchEvent(event);\n       });\n \n+      this.inputElement.addEventListener(\"blur\", (e) => {\n+        const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n+        me.dispatchEvent(event);\n+      }, true);\n+\n       this.container.appendChild(this.inputElement);\n     }\n \n@@ -226,7 +234,7 @@ class GenericForm extends HTMLElement {\n \n   constructor() {\n     super();\n-    this.attachShadow({ mode: 'open' });\n+    this.attachShadow({mode: 'open'});\n     this.styleElement = document.createElement(\"style\");\n     this.styleElement.innerHTML = `\n \n@@ -307,6 +315,16 @@ class GenericForm extends HTMLElement {\n             }\n           }\n         });\n+\n+        fieldElement.addEventListener(\"submit\", async (e) => {\n+          const isValid = await this.validate();\n+          if (isValid) {\n+            const saveResult = await this.submit();\n+            if (saveResult.redirect_url) {\n+              window.location.pathname = saveResult.redirect_url;\n+            }\n+          }\n+        })\n       });\n \n     } catch (error) {\n@@ -322,7 +340,7 @@ class GenericForm extends HTMLElement {\n       headers: {\n         'Content-Type': 'application/json'\n       },\n-      body: JSON.stringify({ \"action\": \"validate\", \"form\": this.form })\n+      body: JSON.stringify({\"action\": \"validate\", \"form\": this.form})\n     });\n \n     const form = await response.json();\n@@ -353,7 +371,7 @@ class GenericForm extends HTMLElement {\n       headers: {\n         'Content-Type': 'application/json'\n       },\n-      body: JSON.stringify({ \"action\": \"submit\", \"form\": this.form })\n+      body: JSON.stringify({\"action\": \"submit\", \"form\": this.form})\n     });\n     return await response.json();\n   }"}
{"repo": ".", "date": "2025-03-08", "line": "docs: Added meta information to base.html", "commit": "7c22a70722db3fa97a813c30c01c4cd5462138eb", "diff": "commit 7c22a70722db3fa97a813c30c01c4cd5462138eb\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:25:15 2025 +0100\n\n    Added additional meta info to base.html\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 0785366..1e89c50 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -3,7 +3,15 @@\n <head>\n     <meta charset=\"UTF-8\">\n     <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <meta name=\"application-name\" content=\"Snek chat by Molodetz\">\n+    <meta name=\"description\" content=\"Snek chat by Molodetz\">\n+    <meta name=\"author\" content=\"Molodetz\">\n+    <meta name=\"keywords\" content=\"snek, chat, molodetz\">\n+    <meta name=\"color-scheme\" content=\"dark\">\n+\n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n+\n     <script src=\"/app.js\"></script>\n     <script src=\"/message-list.js\"></script>\n     <style>{{ highlight_styles }}</style>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort threads by last message timestamp", "commit": "8e195a49e3e914a4b241e95378bd9a07611715a8", "diff": "commit 8e195a49e3e914a4b241e95378bd9a07611715a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 18:41:37 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 772685f..14431f1 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -27,6 +27,7 @@ class ThreadsView(BaseView):\n                 thread['name_color'] = user_last_message['color']\n             thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n-\n+            \n+        threads.sort(key=lambda x: x['last_message_on'], reverse=True)\n \n         return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-08", "line": "style: Improved form input focus and placeholder styling", "commit": "c9113ca09500c5b3cc277fb09b9607a505d39f30", "diff": "commit c9113ca09500c5b3cc277fb09b9607a505d39f30\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:56:55 2025 +0100\n\n    Tweaked form input styling\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex e71d817..11647dc 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -75,16 +75,28 @@ class GenericField extends HTMLElement {\n       }\n \n       input {\n-          width: 90%;\n-          padding: 10px;\n-          margin: 10px 0;\n-          border-radius: 5px;\n-          font-size: 1em;\n+        width: 90%;\n+        padding: 10px;\n+        margin: 10px 0;\n+        border-radius: 5px;\n+        font-size: 1em;\n+        \n+        &:focus {\n+        }\n+        \n+        &::placeholder {\n+          transition: opacity 0.3s;\n+        }\n+        \n+        &:focus::placeholder {\n+          opacity: 0.4;\n+        }\n       }\n-\n+      \n       button {\n           width: 50%;\n           padding: 10px;"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added head block for custom HTML head content", "commit": "62aa15a4b4d6514824378cca73084c9ce2df903b", "diff": "commit 62aa15a4b4d6514824378cca73084c9ce2df903b\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:59:00 2025 +0100\n\n    Added head block\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 1e89c50..d93b568 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -20,6 +20,9 @@\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n+\n+    {% block head %}\n+    {% endblock %}\n </head>\n <body>\n <header>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented back button styling and layout", "commit": "ad7eab9717848584369ded6e19babb6a7b9f5b98", "diff": "commit ad7eab9717848584369ded6e19babb6a7b9f5b98\nAuthor: BordedDev <>\nDate:   Sat Mar 8 19:07:09 2025 +0100\n\n    Tweaked the back button position\n\ndiff --git a/src/snek/static/back-form.css b/src/snek/static/back-form.css\nnew file mode 100644\nindex 0000000..4824d6c\n--- /dev/null\n+++ b/src/snek/static/back-form.css\n@@ -0,0 +1,18 @@\n+.back-form {\n+  display: grid;\n+  grid-template-columns: auto auto;\n+  grid-template-rows: auto auto;\n+\n+  fancy-button {\n+    grid-column: 1 / 1;\n+    grid-row: 1 / 1;\n+    z-index: 1;\n+    margin-left: 30px;\n+    margin-top: 30px;\n+  }\n+\n+  generic-form {\n+    grid-column: 1 / 3;\n+    grid-row: 1 / 3;\n+  }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 0ffc379..ed81224 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -4,8 +4,13 @@\n     Login - Snek chat by Molodetz\n {% endblock %}\n \n-{% block main %}\n-    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-    <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+{% block head %}\n+    <link rel=\"stylesheet\" href=\"/back-form.css\">\n+{% endblock %}\n \n+{% block main %}\n+    <div class=\"back-form\">\n+        <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+        <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+    </div>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 2b783a7..2fa89d3 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -4,8 +4,14 @@\n     Register - Snek chat by Molodetz\n {% endblock %}\n \n+{% block head %}\n+    <link rel=\"stylesheet\" href=\"/back-form.css\">\n+{% endblock %}\n+\n {% block main %}\n-    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+    <div class=\"back-form\">\n+        <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n \n-    <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+    </div>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Revert whitespace changes", "commit": "e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0", "diff": "commit e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0\nAuthor: BordedDev <>\nDate:   Sat Mar 8 19:35:22 2025 +0100\n\n    Undo auto formatting\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex bcdc1d1..3d154a9 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,345 +1,345 @@\n * {\n-    margin: 0;\n-    box-sizing: border-box;\n+  margin: 0;\n+  box-sizing: border-box;\n }\n \n .gallery {\n-    padding: 50px;\n-    height: auto;\n-    overflow: auto;\n-    flex: 1;\n+  padding: 50px;\n+  height: auto;\n+  overflow: auto;\n+  flex: 1;\n }\n \n .gallery.tile, .tile {\n-    width: 100px;\n-    height: 100px;\n-    object-fit: cover;\n-    margin: 20px 10px 20px 0;\n-    border-radius: 5px;\n+  width: 100px;\n+  height: 100px;\n+  object-fit: cover;\n+  margin: 20px 10px 20px 0;\n+  border-radius: 5px;\n }\n \n body {\n-    font-family: Arial, sans-serif;\n-    line-height: 1.5;\n-    display: flex;\n-    flex-direction: column;\n-    height: 100vh;\n-    min-width: 100%;\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+  min-width: 100%;\n }\n \n main {\n-    display: flex;\n-    flex: 1;\n-    overflow: hidden;\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n }\n \n header {\n-    padding: 10px 20px;\n-    display: flex;\n-    justify-content: space-between;\n-    align-items: center;\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n }\n \n header .logo {\n-    font-size: 1.5em;\n-    font-weight: bold;\n+  font-size: 1.5em;\n+  font-weight: bold;\n }\n \n header nav a {\n-    text-decoration: none;\n-    margin-left: 15px;\n-    font-size: 1em;\n-    transition: color 0.3s;\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n }\n \n .no-select {\n-    -webkit-user-select: none;\n-    -moz-user-select: none;\n-    -ms-user-select: none;\n-    user-select: none;\n+  -webkit-user-select: none;\n+  -moz-user-select: none;\n+  -ms-user-select: none;\n+  user-select: none;\n }\n \n header nav a:hover {\n }\n \n a {\n-    font-weight: bold;\n-    margin-bottom: 3px;\n+  font-weight: bold;\n+  margin-bottom: 3px;\n }\n \n .chat-area {\n-    flex: 1;\n-    display: flex;\n-    flex-direction: column;\n-    overflow: hidden;\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+  overflow: hidden;\n }\n \n .chat-header {\n-    padding: 10px 20px;\n-    user-select: none;\n+  padding: 10px 20px;\n+  user-select: none;\n }\n \n .chat-header h2 {\n-    font-size: 1.2em;\n+  font-size: 1.2em;\n \n }\n \n .message-list {\n-    flex: 1;\n-    height: 200px;\n-    padding-bottom: 40px;\n-    overflow-y: auto;\n+  flex: 1;\n+  height: 200px;\n+  padding-bottom: 40px;\n+  overflow-y: auto;\n }\n \n .chat-messages, .threads {\n-    flex: 1;\n-    overflow-y: scroll;\n-    scrollbar-width: none;\n-    -ms-overflow-style: none;\n-    padding: 10px;\n-    height: 200px;\n+  flex: 1;\n+  overflow-y: scroll;\n+  scrollbar-width: none;\n+  -ms-overflow-style: none;\n+  padding: 10px;\n+  height: 200px;\n }\n \n .container {\n-    flex: 1;\n-    padding: 10px;\n+  flex: 1;\n+  padding: 10px;\n \n-    ul {\n-        list-style: none;\n-        margin: 0;\n-        padding: 0;\n-    }\n+  ul {\n+    list-style: none;\n+    margin: 0;\n+    padding: 0;\n+  }\n \n-    a {\n-        font-size: 20px;\n-    }\n+  a {\n+    font-size: 20px;\n+  }\n \n }\n \n .chat-messages::-webkit-scrollbar {\n-    display: none;\n+  display: none;\n }\n \n .chat-messages .message, .threads .thread {\n-    display: flex;\n-    align-items: flex-start;\n-    margin-bottom: 0;\n-    padding: 5px;\n-    border-radius: 8px;\n+  display: flex;\n+  align-items: flex-start;\n+  margin-bottom: 0;\n+  padding: 5px;\n+  border-radius: 8px;\n }\n \n .chat-messages .message .avatar, .threads .thread .avatar {\n-    width: 40px;\n-    height: 40px;\n-    border-radius: 50%;\n-    font-size: 1em;\n-    font-weight: bold;\n-    display: flex;\n-    justify-content: center;\n-    align-items: center;\n-    margin-right: 15px;\n+  width: 40px;\n+  height: 40px;\n+  border-radius: 50%;\n+  font-size: 1em;\n+  font-weight: bold;\n+  display: flex;\n+  justify-content: center;\n+  align-items: center;\n+  margin-right: 15px;\n }\n \n .chat-messages .message .message-content, .threads .thread .message-content {\n-    flex: 1;\n+  flex: 1;\n }\n \n .chat-messages .message .message-content .author, .threads .thread .message-content .author {\n-    font-weight: bold;\n-    margin-bottom: 3px;\n+  font-weight: bold;\n+  margin-bottom: 3px;\n }\n \n * {\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .highlight pre {\n-    white-space: pre-wrap;\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  white-space: pre-wrap;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .chat-messages .message .message-content .text, .threads .thread .message-content .text {\n-    margin-bottom: 5px;\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  margin-bottom: 5px;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .message-content {\n-    max-width: 100%;\n+  max-width: 100%;\n }\n \n .message-content {\n \n-    img, video, iframe, div {\n-        max-width: 90%;\n-        border-radius: 20px;\n-    }\n+  img, video, iframe, div {\n+    max-width: 90%;\n+    border-radius: 20px;\n+  }\n }\n \n .chat-messages .message .message-content .time, .threads .thread .message-content .time {\n-    font-size: 0.8em;\n+  font-size: 0.8em;\n }\n \n .chat-input {\n-    padding: 15px;\n-    display: flex;\n-    align-items: center;\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n-    flex: 1;\n-    color: white;\n-    border: none;\n-    padding: 10px;\n-    border-radius: 5px;\n-    resize: none;\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n }\n \n .chat-input upload-button {\n-    color: white;\n-    border: none;\n-    padding: 10px 15px;\n-    margin-left: 10px;\n-    border-radius: 5px;\n-    cursor: pointer;\n-    font-size: 1em;\n-    transition: background-color 0.3s;\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n }\n \n .chat-input button:hover {\n }\n \n @media (max-width: 768px) {\n-    .sidebar {\n-        display: none;\n-    }\n+  .sidebar {\n+    display: none;\n+  }\n }\n \n .message {\n-    .text {\n-        max-width: 100%;\n-        word-wrap: break-word;\n-        overflow-wrap: break-word;\n-        hyphens: auto;\n-    }\n+  .text {\n+    max-width: 100%;\n+    word-wrap: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n+  }\n \n-    .avatar {\n-        opacity: 0;\n-    }\n+  .avatar {\n+    opacity: 0;\n+  }\n \n-    .author, .time {\n-        display: none;\n-    }\n+  .author, .time {\n+    display: none;\n+  }\n }\n \n .message.switch-user {\n-    .text img, iframe, video {\n-        max-width: 90%;\n-        border-radius: 20px;\n-    }\n+  .text img, iframe, video {\n+    max-width: 90%;\n+    border-radius: 20px;\n+  }\n \n-    .avatar {\n-        user-select: none;\n-        opacity: 1;\n-    }\n+  .avatar {\n+    user-select: none;\n+    opacity: 1;\n+  }\n \n-    .author {\n-        display: block;\n-    }\n+  .author {\n+    display: block;\n+  }\n }\n \n .message:has(+ .message.switch-user), .message:last-child {\n-    .time {\n-        display: block;\n-    }\n+  .time {\n+    display: block;\n+  }\n }\n \n ::-webkit-scrollbar {\n-    display: none;\n+  display: none;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n-    border-radius: 3px;\n+  border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n .chat-messages, .threads {\n-    scrollbar-width: none;\n+  scrollbar-width: none;\n }\n \n a {\n-    text-decoration: none\n+  text-decoration: none\n }\n \n .sidebar {\n-    width: 250px;\n-    padding: 20px;\n-    overflow-y: auto;\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n }\n \n .sidebar h2 {\n-    font-size: 1.2em;\n-    margin-bottom: 20px;\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n }\n \n .sidebar ul {\n-    list-style: none;\n+  list-style: none;\n }\n \n .sidebar ul li {\n-    margin-bottom: 15px;\n+  margin-bottom: 15px;\n }\n \n .sidebar ul li a {\n-    text-decoration: none;\n-    font-size: 1em;\n-    transition: color 0.3s;\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n }\n \n .sidebar ul li a:hover {\n }"}
{"repo": ".", "date": "2025-03-08", "line": "Merge main", "commit": "dd5a9a23e8e452a03f7080656608617309bf73a5", "diff": "commit dd5a9a23e8e452a03f7080656608617309bf73a5\nMerge: e91ae45 8e195a4\nAuthor: BordedDev <bordeddev@noreply@molodetz.nl>\nDate:   Sat Mar 8 18:40:43 2025 +0000\n\n    Merge branch 'main' into main"}
{"repo": ".", "date": "2025-03-08", "line": "Merge pull request #123\n", "commit": "aedfe9aa947dcd2262c825af5a4d977eb298ccb5", "diff": "commit aedfe9aa947dcd2262c825af5a4d977eb298ccb5\nMerge: 8e195a4 dd5a9a2\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sat Mar 8 18:53:02 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort channels by last message time", "commit": "a219ce4d79a15ef900583ab025fb0da1df79ace3", "diff": "commit a219ce4d79a15ef900583ab025fb0da1df79ace3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:21:02 2025 +0100\n\n    Update sorting.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 3ae7902..be44df9 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -58,6 +58,9 @@ class WebView(BaseView):\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n             item = {}\n             other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n+            parent_object = await subscribed_channel.get_channel()\n+            last_message =await parent_object.get_last_message()\n+            item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n@@ -65,6 +68,8 @@ class WebView(BaseView):\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n+        \n+        channels.sort(key=lambda x: x['last_message_on'], reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Separate public and private channels in sidebar", "commit": "d6061cb45b68a7b393b0e35a560d5d7bea4b9478", "diff": "commit d6061cb45b68a7b393b0e35a560d5d7bea4b9478\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:27:05 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex ed16ffd..9dd20b5 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -6,7 +6,13 @@\n <aside class=\"sidebar\" id=\"channelSidebar\">\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n-      {% for channel in channels %}\n+      {% for channel in channels if not channel['is_private'] %}\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      {% endfor %}\n+    </ul>\n+    <h2 class=\"no-select\">Private</h2>\n+    <ul>\n+      {% for channel in channels if channel['is_private'] %}\n       <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n       {% endfor %}\n     </ul>\n@@ -61,4 +67,4 @@\n     }\n     const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n \n-  </script>\n\\ No newline at end of file\n+  </script>\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex be44df9..6444ce3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -61,6 +61,7 @@ class WebView(BaseView):\n             parent_object = await subscribed_channel.get_channel()\n             last_message =await parent_object.get_last_message()\n             item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+            item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message, handling null values", "commit": "24a504e3a7383c7a338fbe3ee09411547eed58eb", "diff": "commit 24a504e3a7383c7a338fbe3ee09411547eed58eb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:29:33 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 6444ce3..77fb4ff 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,7 +70,7 @@ class WebView(BaseView):\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n         \n-        channels.sort(key=lambda x: x['last_message_on'], reverse=True)\n+        channels.sort(key=lambda x: x['last_message_on'] or 'zzzzz', reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message time, handling null values", "commit": "11b8f0e744fb9d6b05ce11b7475bb3f51edee96b", "diff": "commit 11b8f0e744fb9d6b05ce11b7475bb3f51edee96b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:29:48 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 77fb4ff..cdde6e3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,7 +70,7 @@ class WebView(BaseView):\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n         \n-        channels.sort(key=lambda x: x['last_message_on'] or 'zzzzz', reverse=True)\n+        channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented form preloading and autofocus on the first input element for login and register pages", "commit": "0266b2a559952d0ff767b251c2921704a6aa1abe", "diff": "commit 0266b2a559952d0ff767b251c2921704a6aa1abe\nAuthor: BordedDev <>\nDate:   Sat Mar 8 21:04:32 2025 +0100\n\n    Added form preloading, and autofocus on the first input element\n    Also adds the preloading functionality to login & register pages\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 11647dc..73e55cb 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -98,58 +98,58 @@ class GenericField extends HTMLElement {\n       }\n       \n       button {\n-          width: 50%;\n-          padding: 10px;\n-          border: none;\n-          float: right;\n-          margin-top: 10px;\n-          margin-left: 10px;\n-          margin-right: 10px;\n-          border-radius: 5px;\n-          color: white;\n-          font-size: 1em;\n-          font-weight: bold;\n-          cursor: pointer;\n-          transition: background-color 0.3s;\n-          clear: both;\n+        width: 50%;\n+        padding: 10px;\n+        border: none;\n+        float: right;\n+        margin-top: 10px;\n+        margin-left: 10px;\n+        margin-right: 10px;\n+        border-radius: 5px;\n+        color: white;\n+        font-size: 1em;\n+        font-weight: bold;\n+        cursor: pointer;\n+        transition: background-color 0.3s;\n+        clear: both;\n       }\n \n       button:hover {\n       }\n \n       a {\n-          text-decoration: none;\n-          display: block;\n-          margin-top: 15px;\n-          font-size: 0.9em;\n-          transition: color 0.3s;\n+        text-decoration: none;\n+        display: block;\n+        margin-top: 15px;\n+        font-size: 0.9em;\n+        transition: color 0.3s;\n       }\n \n       a:hover {\n       }\n \n       .valid {\n-          border: 1px solid green;\n-          color: green;\n-          font-size: 0.9em;\n-          margin-top: 5px;\n+        border: 1px solid green;\n+        color: green;\n+        font-size: 0.9em;\n+        margin-top: 5px;\n       }\n \n       .error {\n         border: 3px solid red;\n-          font-size: 0.9em;\n-          margin-top: 5px;\n+        font-size: 0.9em;\n+        margin-top: 5px;\n       }\n \n       @media (max-width: 500px) {\n-          input {\n-              width: 90%;\n-          }\n+        input {\n+          width: 90%;\n+        }\n       }\n     `;\n     this.container.appendChild(this.styleElement);\n@@ -165,7 +165,13 @@ class GenericField extends HTMLElement {\n     this[name] = value;\n   }\n \n+  focus(options) {\n+    this.inputElement?.focus(options);\n+  }\n+\n   updateAttributes() {\n+    const inputUpdate = this.inputElement != null;\n+\n     if (this.inputElement == null && this.field) {\n       this.inputElement = document.createElement(this.field.tag);\n       if (this.field.tag === 'button' && this.field.value === \"submit\") {\n@@ -218,7 +224,9 @@ class GenericField extends HTMLElement {\n     }\n     this.inputElement.setAttribute(\"tabindex\", this.field.index);\n     this.inputElement.classList.add(this.field.name);\n-    this.value = this.field.value;\n+    if (this.field.value != null || !inputUpdate) {\n+      this.value = this.field.value;\n+    }\n \n     let place_holder = this.field.place_holder ?? null;\n     if (this.field.required && place_holder) {\n@@ -281,43 +289,73 @@ class GenericForm extends HTMLElement {\n   }\n \n   connectedCallback() {\n+    const preloadedForm = this.getAttribute('preloaded-structure');\n+    if (preloadedForm) {\n+      try {\n+        const form = JSON.parse(preloadedForm);\n+        this.constructForm(form)\n+      } catch (error) {\n+        console.error(error, preloadedForm);\n+      }\n+    }\n     const url = this.getAttribute('url');\n     if (url) {\n       const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n       if (!url.startsWith(\"/\")) {\n         fullUrl.searchParams.set('url', url);\n       }\n-      this.loadForm(fullUrl.toString());\n+      this.loadForm(fullUrl.toString())\n     } else {\n       this.container.textContent = \"No URL provided!\";\n     }\n   }\n \n-  async loadForm(url) {\n+  async constructForm(formPayload) {\n     try {\n-      const response = await fetch(url);\n-      if (!response.ok) {\n-        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n-      }\n-      this.form = await response.json();\n+      this.form = formPayload;\n \n       let fields = Object.values(this.form.fields);\n \n+      let hasAutoFocus = Object.keys(this.fields).length !== 0;\n+\n       fields.sort((a, b) => a.index - b.index);\n       fields.forEach(field => {\n-        const fieldElement = document.createElement('generic-field');\n-        this.fields[field.name] = fieldElement;\n+        const updatingField = field.name in this.fields\n+\n+        this.fields[field.name] ??= document.createElement('generic-field');\n+\n+        const fieldElement = this.fields[field.name];\n+\n         fieldElement.setAttribute(\"form\", this);\n         fieldElement.setAttribute(\"field\", field);\n-        this.container.appendChild(fieldElement);\n+\n         fieldElement.updateAttributes();\n \n-        fieldElement.addEventListener(\"change\", (e) => {\n-          this.form.fields[e.detail.name].value = e.detail.value;\n-        });\n+        if (!updatingField) {\n+          this.container.appendChild(fieldElement);\n+\n+          if (!hasAutoFocus && field.tag === \"input\") {\n+            fieldElement.focus();\n+            hasAutoFocus = true;\n+          }\n+\n+          fieldElement.addEventListener(\"change\", (e) => {\n+            this.form.fields[e.detail.name].value = e.detail.value;\n+          });\n+\n+          fieldElement.addEventListener(\"click\", async (e) => {\n+            if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+              const isValid = await this.validate();\n+              if (isValid) {\n+                const saveResult = await this.submit();\n+                if (saveResult.redirect_url) {\n+                  window.location.pathname = saveResult.redirect_url;\n+                }\n+              }\n+            }\n+          });\n \n-        fieldElement.addEventListener(\"click\", async (e) => {\n-          if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+          fieldElement.addEventListener(\"submit\", async (e) => {\n             const isValid = await this.validate();\n             if (isValid) {\n               const saveResult = await this.submit();\n@@ -325,20 +363,22 @@ class GenericForm extends HTMLElement {\n                 window.location.pathname = saveResult.redirect_url;\n               }\n             }\n-          }\n-        });\n-\n-        fieldElement.addEventListener(\"submit\", async (e) => {\n-          const isValid = await this.validate();\n-          if (isValid) {\n-            const saveResult = await this.submit();\n-            if (saveResult.redirect_url) {\n-              window.location.pathname = saveResult.redirect_url;\n-            }\n-          }\n-        })\n+          })\n+        }\n       });\n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n+    }\n+  }\n+\n+  async loadForm(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+      }\n \n+      await this.constructForm(await response.json());\n     } catch (error) {\n       this.container.textContent = `Error: ${error.message}`;\n     }\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex ed81224..d91920c 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -11,6 +11,6 @@\n {% block main %}\n     <div class=\"back-form\">\n         <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-        <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/login.json\" preloaded-structure='{{ form|tojson|safe }}'></generic-form>\n     </div>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 2fa89d3..21f8fe0 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -12,6 +12,6 @@\n     <div class=\"back-form\">\n         <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n \n-        <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/register.json\" preloaded-structure='{{ form|tojson|safe }}'></generic-form>\n     </div>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 580655f..6d6d6ad 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -18,7 +18,7 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\")\n+        return await self.render_template(\"login.html\", {\"form\": await self.form(app=self.app).to_json()})\n \n     async def submit(self, form):\n         if await form.is_valid:\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex fdcc9ad..db812b5 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -18,7 +18,7 @@ class RegisterView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"register.html\")\n+        return await self.render_template(\"register.html\", {\"form\": await self.form(app=self.app).to_json()})\n \n     async def submit(self, form):\n         result = await self.app.services.user.register("}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected semicolon in loadForm call", "commit": "fd07001983fc3d3015ac7064461c14b8486155e6", "diff": "commit fd07001983fc3d3015ac7064461c14b8486155e6\nAuthor: BordedDev <>\nDate:   Sat Mar 8 21:18:20 2025 +0100\n\n    Returned a semi\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 73e55cb..319d7d3 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -304,7 +304,7 @@ class GenericForm extends HTMLElement {\n       if (!url.startsWith(\"/\")) {\n         fullUrl.searchParams.set('url', url);\n       }\n-      this.loadForm(fullUrl.toString())\n+      this.loadForm(fullUrl.toString());\n     } else {\n       this.container.textContent = \"No URL provided!\";\n     }"}
{"repo": ".", "date": "2025-03-09", "line": "Merge pull request #123\n", "commit": "d9ac1813ba8ddad9fb602730cb2cc763aab4bc23", "diff": "commit d9ac1813ba8ddad9fb602730cb2cc763aab4bc23\nMerge: 11b8f0e fd07001\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 9 18:37:34 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-09", "line": "fix: Sort threads by last message, handling missing timestamps", "commit": "91d8f3efd16431fe99b0e60927d1f6d9b6587f7e", "diff": "commit 91d8f3efd16431fe99b0e60927d1f6d9b6587f7e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 9 20:38:14 2025 +0100\n\n    Added alter.\n\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 14431f1..4f3fe8c 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -28,6 +28,6 @@ class ThreadsView(BaseView):\n             thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n             \n-        threads.sort(key=lambda x: x['last_message_on'], reverse=True)\n+        threads.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n         return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-10", "line": "fix: Improved search user page layout", "commit": "c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8", "diff": "commit c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 10 11:53:54 2025 +0100\n\n    List fix.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 842b982..453f7e3 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -8,7 +8,7 @@\n     <div class=\"chat-header\">\n         <h2>Search user</h2>\n     </div>\n-    <div class=\"container\">\n+    <div class=\"container chat-area\">\n         <form method=\"get\" action=\"/search-user.html\">\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n             <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>"}
{"repo": ".", "date": "2025-03-11", "line": "feat: Improved file download and naming conventions", "commit": "c6c2766381f75b058fb61f91556788b0720b058b", "diff": "commit c6c2766381f75b058fb61f91556788b0720b058b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 11 10:10:50 2025 +0100\n\n    Added better file handling.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex f9bad33..8884d72 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -25,6 +25,7 @@ class UploadView(BaseView):\n         drive_item = await self.services.drive_item.get(uid)\n         response = web.FileResponse(drive_item[\"path\"])\n         response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n+        response.headers['Content-Disposition'] = f'attachment; filename=\"{drive_item[\"name\"]}\"'\n         return response\n \n     async def post(self):\n@@ -57,8 +58,10 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n+            \n+            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n-            file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n+            file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n             files.append(file_path)\n \n             async with aiofiles.open(str(file_path.absolute()), 'wb') as f:"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added reply functionality and improved time display", "commit": "5cfcafe0821b3cceb753b9ddb4076a79f26a88c0", "diff": "commit 5cfcafe0821b3cceb753b9ddb4076a79f26a88c0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:15:10 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2c48cb4..cdf7672 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,6 +22,10 @@\n <script>\n     const channelUid = \"{{ channel.uid.value }}\";\n \n+    function getInputField(){\n+        return document.querySelector(\"textarea\")\n+    }\n+        \n     function initInputField(textBox) {\n         textBox.addEventListener('change', (e) => {\n             e.preventDefault();\n@@ -41,9 +45,28 @@\n         textBox.focus();\n     }\n \n+    function replyMessage(message) {\n+        const field = getInputField() \n+        field.value = \"```\\n\" + (message || '') + \"\\n```\\n\";\n+        field.focus();\n+    }\n+\n     function updateTimes() {\n-        document.querySelectorAll(\".time\").forEach((time) => {\n+        document.querySelectorAll(\".time\").forEach((container) => {\n+            const messageDiv = container.closest('.message');\n+            const userNick = messageDiv.dataset.user_nick;\n+            const text = messageDiv.querySelector(\".text\").innerText;\n+            const time = document.createElement(\"span\");\n             time.innerText = app.timeDescription(time.dataset.created_at);\n+            container.replaceChildren(time);\n+            const reply = document.createElement(\"a\");\n+            reply.innerText = \" reply\";\n+            container.appendChild(reply);\n+            reply.addEventListener('click', (e) => {\n+                e.preventDefault();\n+                replyMessage(text);\n+            })\n         });\n     }\n \n@@ -159,7 +182,7 @@\n         }, 1000);\n     });\n \n-    initInputField(document.querySelector(\"textarea\"));\n+    initInputField(getInputField());\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Display creation time on container", "commit": "c55927aa9c7e575544901bbee41cb9a9d3c6437a", "diff": "commit c55927aa9c7e575544901bbee41cb9a9d3c6437a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:21:38 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex cdf7672..d76e9f0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -57,7 +57,8 @@\n             const userNick = messageDiv.dataset.user_nick;\n             const text = messageDiv.querySelector(\".text\").innerText;\n             const time = document.createElement(\"span\");\n-            time.innerText = app.timeDescription(time.dataset.created_at);\n+            time.innerText = app.timeDescription(container.dataset.created_at);\n+            \n             container.replaceChildren(time);\n             const reply = document.createElement(\"a\");\n             reply.innerText = \" reply\";"}
{"repo": ".", "date": "2025-03-13", "line": "style: Adjusted sidebar padding for better responsiveness", "commit": "0fad298fc078e2a3ea1afee71ee92b99b83427b0", "diff": "commit 0fad298fc078e2a3ea1afee71ee92b99b83427b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:51:01 2025 +0100\n\n    Blacked.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 3d154a9..b683324 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -20,7 +20,7 @@\n \n body {\n   font-family: Arial, sans-serif;\n   line-height: 1.5;\n   display: flex;\n@@ -36,7 +36,7 @@ main {\n }\n \n header {\n   padding: 10px 20px;\n   display: flex;\n   justify-content: space-between;\n@@ -78,14 +78,13 @@ a {\n   flex: 1;\n   display: flex;\n   flex-direction: column;\n   overflow: hidden;\n }\n \n .chat-header {\n   padding: 10px 20px;\n   user-select: none;\n }\n \n@@ -109,7 +108,7 @@ a {\n   -ms-overflow-style: none;\n   padding: 10px;\n   height: 200px;\n }\n \n .container {\n@@ -205,15 +204,14 @@ a {\n \n .chat-input {\n   padding: 15px;\n   display: flex;\n   align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n   flex: 1;\n   color: white;\n   border: none;\n   padding: 10px;\n@@ -312,10 +310,10 @@ a {\n \n .sidebar {\n   width: 250px;\n-  padding: 20px;\n+  padding-left: 20px;\n+  padding-right: 20px;\n   overflow-y: auto;\n }\n \n .sidebar h2 {"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added top padding to links", "commit": "0f950218d6d783c4738966e15b2746c14043c82f", "diff": "commit 0f950218d6d783c4738966e15b2746c14043c82f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:52:03 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex b683324..448cb28 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -313,6 +313,7 @@ a {\n   padding-left: 20px;\n   padding-right: 20px;\n+  padding-top: 10px;\n   overflow-y: auto;\n }"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Improved reply formatting with markdown and blockquote", "commit": "d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139", "diff": "commit d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 20:36:14 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d76e9f0..a6efee3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,7 +47,7 @@\n \n     function replyMessage(message) {\n         const field = getInputField() \n-        field.value = \"```\\n\" + (message || '') + \"\\n```\\n\";\n+        field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }"}
{"repo": ".", "date": "2025-03-14", "line": "feat: Increased update interval for times", "commit": "17c9731b9f8be07b247aeed29ba2ab1319e408f0", "diff": "commit 17c9731b9f8be07b247aeed29ba2ab1319e408f0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 14 23:02:58 2025 +0100\n\n    Done stuff.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex a6efee3..e58af53 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -138,7 +138,7 @@\n         }\n     }\n \n-    setInterval(updateTimes, 1000);\n+    setInterval(updateTimes, 30000);\n \n     function isMentionToMe(message){\n         const mentionText = '@{{ user.username.value }}';"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor input styling into shared CSS file", "commit": "5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621", "diff": "commit 5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621\nAuthor: BordedDev <>\nDate:   Sat Mar 15 15:53:24 2025 +0100\n\n    Added updated input styling to other pages\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 448cb28..04610da 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,3 +1,5 @@\n+@import \"shared.css\";\n+\n * {\n   margin: 0;\n   box-sizing: border-box;\ndiff --git a/src/snek/static/shared.css b/src/snek/static/shared.css\nnew file mode 100644\nindex 0000000..d7a652b\n--- /dev/null\n+++ b/src/snek/static/shared.css\n@@ -0,0 +1,15 @@\n+\n+\n+input, textarea {\n+  &:focus {\n+  }\n+\n+  &::placeholder {\n+    transition: opacity 0.3s;\n+  }\n+\n+  &:focus::placeholder {\n+    opacity: 0.4;\n+  }\n+}\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 63a28ed..0661225 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,3 +1,4 @@\n+@import \"shared.css\";\n \n * {"}
{"repo": ".", "date": "2025-03-15", "line": "Merge: Resolved merge conflicts and updated dependencies.", "commit": "752f3df13a548d22646f87a87940dc64e15587f3", "diff": "commit 752f3df13a548d22646f87a87940dc64e15587f3\nMerge: 17c9731 5b70bb9\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sat Mar 15 15:27:38 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor app to use ES modules and update script tags", "commit": "a4d79b06c49ec9f605336bf181e98455c8acd460", "diff": "commit a4d79b06c49ec9f605336bf181e98455c8acd460\nAuthor: BordedDev <>\nDate:   Sat Mar 15 19:10:52 2025 +0100\n\n    Updated files to support modules\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f26919e..29aedb2 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,7 +7,9 @@\n \n \n-class RESTClient {\n+import { Schedule } from './schedule.js';\n+\n+export class RESTClient {\n     debug = false;\n \n     async get(url, params = {}) {\n@@ -43,7 +45,7 @@ class RESTClient {\n     }\n }\n \n-class EventHandler {\n+export class EventHandler {\n     constructor() {\n         this.subscribers = {};\n     }\n@@ -58,7 +60,7 @@ class EventHandler {\n     }\n }\n \n-class Chat extends EventHandler {\n+export class Chat extends EventHandler {\n     constructor() {\n         super();\n@@ -132,7 +134,7 @@ class Chat extends EventHandler {\n     }\n }\n \n-class Socket extends EventHandler {\n+export class Socket extends EventHandler {\n     ws = null;\n     isConnected = null;\n     isConnecting = null;\n@@ -259,7 +261,7 @@ class Socket extends EventHandler {\n     }\n }\n \n-class NotificationAudio {\n+export class NotificationAudio {\n     constructor(timeout = 500) {\n         this.schedule = new Schedule(timeout);\n     }\n@@ -284,7 +286,7 @@ class NotificationAudio {\n     }\n }\n \n-class App extends EventHandler {\n+export class App extends EventHandler {\n     rest = new RESTClient();\n     ws = null;\n     rpc = null;\n@@ -366,4 +368,4 @@ class App extends EventHandler {\n     }\n }\n \n-const app = new App();\n+export const app = new App();\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 36ae803..7e3add5 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -9,7 +9,7 @@\n \n-class Schedule {\n+export class Schedule {\n   constructor(msDelay = 100) {\n     this.msDelay = msDelay;\n     this._once = false;\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 60109de..5e30845 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -9,12 +9,11 @@\n   <!-- \n   <script src=\"/push.js\"></script>\n   -->\n-  <script src=\"/fancy-button.js\"></script>\n-  <script src=\"/upload-button.js\"></script>\n-  <script src=\"/generic-form.js\"></script>\n-  <script src=\"/html-frame.js\"></script>\n-  <script src=\"/schedule.js\"></script>\n-  <script src=\"/app.js\"></script>\n+  <script src=\"/fancy-button.js\" type=\"module\"></script>\n+  <script src=\"/upload-button.js\" type=\"module\"></script>\n+  <script src=\"/generic-form.js\" type=\"module\"></script>\n+  <script src=\"/html-frame.js\" type=\"module\"></script>\n+  <script src=\"/app.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n \n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d93b568..a7cc1c5 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -12,13 +12,13 @@\n \n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n \n-    <script src=\"/app.js\"></script>\n-    <script src=\"/message-list.js\"></script>\n+    <script src=\"/app.js\"  type=\"module\"></script>\n+    <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n     <link rel=\"stylesheet\" href=\"/style.css\">\n-    <script src=\"/fancy-button.js\"></script>\n-    <script src=\"/html-frame.js\"></script>\n-    <script src=\"/generic-form.js\"></script>\n+    <script src=\"/fancy-button.js\" type=\"module\"></script>\n+    <script src=\"/html-frame.js\" type=\"module\"></script>\n+    <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n \n     {% block head %}\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 453f7e3..cf35d0d 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -35,7 +35,8 @@\n \n     </div>\n </section>\n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n \n     document.querySelector(\"[name=query]\").focus();\n \ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 73c256a..ae71c6f 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -24,7 +24,8 @@\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n </section>\n \n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n \n     function updateTimes() {\n         document.querySelectorAll(\".time\").forEach((time) => {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e58af53..e1e443b 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -19,7 +19,9 @@\n     </div>\n </section>\n \n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n+\n     const channelUid = \"{{ channel.uid.value }}\";\n \n     function getInputField(){\n@@ -120,6 +122,8 @@\n         loadExtra();\n     });\n \n+    let lastMessage\n+\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n@@ -134,7 +138,7 @@\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n-            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+            lastMessage?.scrollIntoView({ inline: \"nearest\" });\n         }\n     }\n \n@@ -171,7 +175,7 @@\n         }\n         \n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n         const message = document.createElement(\"div\");"}
{"repo": ".", "date": "2025-03-16", "line": "Merge: Improved code review workflow", "commit": "a9663c8170dd2f925100eaa50e8c0019c5eee683", "diff": "commit a9663c8170dd2f925100eaa50e8c0019c5eee683\nMerge: 752f3df a4d79b0\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 16 01:40:06 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Integrated socket connection and event handling for real-time updates.", "commit": "4a8a614adb5e15cad18414214d30ab83464eae14", "diff": "commit 4a8a614adb5e15cad18414214d30ab83464eae14\nAuthor: BordedDev <>\nDate:   Sun Mar 16 04:55:01 2025 +0100\n\n    Replaced socket code\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 29aedb2..e4a59ea 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,7 +7,9 @@\n \n \n-import { Schedule } from './schedule.js';\n+import {Schedule} from './schedule.js';\n+import {EventHandler} from \"./event-handler.js\";\n+import {Socket} from \"./socket.js\";\n \n export class RESTClient {\n     debug = false;\n@@ -23,7 +25,7 @@ export class RESTClient {\n         });\n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url, params, result });\n+            console.debug({url, params, result});\n         }\n         return result;\n     }\n@@ -39,27 +41,12 @@ export class RESTClient {\n \n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url, data, result });\n+            console.debug({url, data, result});\n         }\n         return result;\n     }\n }\n \n-export class EventHandler {\n-    constructor() {\n-        this.subscribers = {};\n-    }\n-\n-    addEventListener(type, handler) {\n-        if (!this.subscribers[type]) this.subscribers[type] = [];\n-        this.subscribers[type].push(handler);\n-    }\n-\n-    emit(type, ...data) {\n-        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n-    }\n-}\n-\n export class Chat extends EventHandler {\n     constructor() {\n         super();\n@@ -100,7 +87,7 @@ export class Chat extends EventHandler {\n     call(method, ...args) {\n         return new Promise((resolve, reject) => {\n             try {\n-                const command = { method, args, message_id: this.generateUniqueId() };\n+                const command = {method, args, message_id: this.generateUniqueId()};\n                 this._promises[command.message_id] = resolve;\n                 this._socket.send(JSON.stringify(command));\n             } catch (e) {\n@@ -134,133 +121,6 @@ export class Chat extends EventHandler {\n     }\n }\n \n-export class Socket extends EventHandler {\n-    ws = null;\n-    isConnected = null;\n-    isConnecting = null;\n-    url = null;\n-    connectPromises = [];\n-    ensureTimer = null;\n-\n-    constructor() {\n-        super();\n-        this.ensureConnection();\n-    }\n-\n-    _camelToSnake(str) {\n-        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n-    }\n-\n-    get client() {\n-        const me = this;\n-        return new Proxy({}, {\n-            get(_, prop) {\n-                return (...args) => {\n-                    const functionName = me._camelToSnake(prop);\n-                    return me.call(functionName, ...args);\n-                };\n-            },\n-        });\n-    }\n-\n-    ensureConnection() {\n-        if (this.ensureTimer) {\n-            return this.connect();\n-        }\n-        const me = this;\n-        this.ensureTimer = setInterval(() => {\n-            if (me.isConnecting) me.isConnecting = false;\n-            me.connect();\n-        }, 5000);\n-        return this.connect();\n-    }\n-\n-    generateUniqueId() {\n-        return 'id-' + Math.random().toString(36).substr(2, 9);\n-    }\n-\n-    connect() {\n-        \n-        const me = this \n-        if (this.isConnected || this.isConnecting) {\n-            return new Promise((resolve) => {\n-                if(me.isConnected)resolve(me)\n-                else if(me.isConnecting)\n-                me.connectPromises.push(resolve);\n-            });\n-        }\n-        this.isConnecting = true;\n-        return new Promise((resolve) => {\n-            this.connectPromises.push(resolve);\n-            console.debug(\"Connecting..\");\n-\n-            const ws = new WebSocket(this.url);\n-            ws.onopen = () => {\n-                this.ws = ws;\n-                this.isConnected = true;\n-                this.isConnecting = false;\n-                ws.onmessage = (event) => {\n-                    this.onData(JSON.parse(event.data));\n-                };\n-                ws.onclose = () => {\n-                    this.onClose();\n-                };\n-                ws.onerror = () => {\n-                    this.onClose();\n-                };\n-                this.onConnect()\n-                this.connectPromises.forEach(resolver => resolver(this));\n-            };\n-        });\n-    }\n-    onConnect(){\n-        this.emit(\"connected\")\n-    }\n-    onData(data) {\n-        if (data.success !== undefined && !data.success) {\n-            console.error(data);\n-        }\n-        if (data.callId) {\n-            this.emit(data.callId, data.data);\n-        }\n-        if (data.channel_uid) {\n-            this.emit(data.channel_uid, data.data);\n-            this.emit(\"channel-message\", data);\n-        }\n-    }\n-\n-    async sendJson(data) {\n-        await this.connect().then(api => {\n-            api.ws.send(JSON.stringify(data));\n-        });\n-    }\n-\n-    async call(method, ...args) {\n-        const call = {\n-            callId: this.generateUniqueId(),\n-            method,\n-            args,\n-        };\n-        const me = this \n-        return new Promise((resolve) => {\n-            me.addEventListener(call.callId, data => resolve(data));\n-            me.sendJson(call);\n-        });\n-    }\n-\n-    onClose() {\n-        console.info(\"Connection lost. Reconnecting.\");\n-        this.isConnected = false;\n-        this.isConnecting = false;\n-        this.ws.close();\n-        this.ws = null;\n-        this.ensureConnection().then(() => {\n-            console.info(\"Reconnected.\");\n-        });\n-    }\n-}\n-\n export class NotificationAudio {\n     constructor(timeout = 500) {\n         this.schedule = new Schedule(timeout);\n@@ -268,9 +128,9 @@ export class NotificationAudio {\n \n     sounds = {\n         \"message\": \"/audio/soundfx.d_beep3.mp3\",\n-        \"mention\":  \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n-        \"messageOtherChannel\":  \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n-        \"ping\":  \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n+        \"mention\": \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n+        \"messageOtherChannel\": \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n+        \"ping\": \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n     }\n \n     play(soundIndex = 0) {\n@@ -294,11 +154,12 @@ export class App extends EventHandler {\n     user = {};\n \n     async ping(...args) {\n-        if(this.is_pinging)return false \n+        if (this.is_pinging) return false\n         this.is_pinging = true\n         await this.rpc.ping(...args);\n         this.is_pinging = false\n     }\n+\n     async forcePing(...arg) {\n         await this.rpc.ping(...args);\n     }\n@@ -308,14 +169,14 @@ export class App extends EventHandler {\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n-        this.is_pinging = false \n-        this.ping_interval = setInterval(()=>{\n+        this.is_pinging = false\n+        this.ping_interval = setInterval(() => {\n             this.ping(\"active\")\n         }, 15000)\n-        \n \n-        const me = this \n-        this.ws.addEventListener(\"connected\", (data)=> {\n+\n+        const me = this\n+        this.ws.addEventListener(\"connected\", (data) => {\n             this.ping(\"online\")\n         })\n         this.ws.addEventListener(\"channel-message\", (data) => {\n@@ -330,6 +191,7 @@ export class App extends EventHandler {\n     playSound(index) {\n         this.audio.play(index);\n     }\n+\n     timeDescription(isoDate) {\n         const date = new Date(isoDate);\n         const hours = String(date.getHours()).padStart(2, \"0\");\n@@ -337,6 +199,7 @@ export class App extends EventHandler {\n         let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n         return timeStr;\n     }\n+\n     timeAgo(date1, date2) {\n         const diffMs = Math.abs(date2 - date1);\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n@@ -355,9 +218,10 @@ export class App extends EventHandler {\n         }\n         return 'just now';\n     }\n+\n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n-        const me = this; \n+        const me = this;\n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n@@ -369,3 +233,4 @@ export class App extends EventHandler {\n }\n \n export const app = new App();\n+window.app = app;\n\\ No newline at end of file\ndiff --git a/src/snek/static/event-handler.js b/src/snek/static/event-handler.js\nnew file mode 100644\nindex 0000000..a6d00e4\n--- /dev/null\n+++ b/src/snek/static/event-handler.js\n@@ -0,0 +1,16 @@\n+\n+\n+export class EventHandler {\n+    constructor() {\n+        this.subscribers = {};\n+    }\n+\n+    addEventListener(type, handler) {\n+        if (!this.subscribers[type]) this.subscribers[type] = [];\n+        this.subscribers[type].push(handler);\n+    }\n+\n+    emit(type, ...data) {\n+        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n+    }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/socket.js b/src/snek/static/socket.js\nnew file mode 100644\nindex 0000000..83f6cac\n--- /dev/null\n+++ b/src/snek/static/socket.js\n@@ -0,0 +1,137 @@\n+import {EventHandler} from \"./event-handler.js\";\n+\n+export class Socket extends EventHandler {\n+     * @type {URL}\n+    url\n+     * @type {WebSocket|null}\n+    ws = null\n+\n+     * @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}\n+    connection = null\n+\n+    shouldReconnect = true;\n+\n+    get isConnected() {\n+        return this.ws && this.ws.readyState === WebSocket.OPEN;\n+    }\n+\n+    get isConnecting() {\n+        return this.ws && this.ws.readyState === WebSocket.CONNECTING;\n+    }\n+\n+    constructor() {\n+        super();\n+\n+        this.url = new URL('/rpc.ws', window.location.origin);\n+        this.url.protocol = this.url.protocol.replace('http', 'ws');\n+\n+        this.connect()\n+    }\n+\n+    connect() {\n+        if (this.ws) {\n+            return this.connection.promise;\n+        }\n+\n+        if (!this.connection || this.connection.resolved) {\n+            this.connection = Promise.withResolvers()\n+        }\n+\n+        this.ws = new WebSocket(this.url);\n+        this.ws.addEventListener(\"open\", () => {\n+            this.connection.resolved = true;\n+            this.connection.resolve(this);\n+            this.emit(\"connected\");\n+        });\n+\n+        this.ws.addEventListener(\"close\", () => {\n+            console.log(\"Connection closed\");\n+            this.disconnect()\n+        })\n+        this.ws.addEventListener(\"error\", (e) => {\n+            console.error(\"Connection error\", e);\n+            this.disconnect()\n+        })\n+        this.ws.addEventListener(\"message\", (e) => {\n+            if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {\n+                console.error(\"Binary data not supported\");\n+            } else {\n+                try {\n+                    this.onData(JSON.parse(e.data));\n+                } catch (e) {\n+                    console.error(\"Failed to parse message\", e);\n+                }\n+            }\n+        })\n+    }\n+\n+\n+    onData(data) {\n+        if (data.success !== undefined && !data.success) {\n+            console.error(data);\n+        }\n+        if (data.callId) {\n+            this.emit(data.callId, data.data);\n+        }\n+        if (data.channel_uid) {\n+            this.emit(data.channel_uid, data.data);\n+            this.emit(\"channel-message\", data);\n+        }\n+    }\n+\n+    disconnect() {\n+        this.ws?.close();\n+        this.ws = null;\n+\n+        if (this.shouldReconnect) setTimeout(() => {\n+            console.log(\"Reconnecting\");\n+            return this.connect();\n+        }, 0);\n+    }\n+\n+\n+    _camelToSnake(str) {\n+        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n+    }\n+\n+    get client() {\n+        const me = this;\n+        return new Proxy({}, {\n+            get(_, prop) {\n+                return (...args) => {\n+                    const functionName = me._camelToSnake(prop);\n+                    return me.call(functionName, ...args);\n+                };\n+            },\n+        });\n+    }\n+\n+    generateCallId() {\n+        return self.crypto.randomUUID();\n+    }\n+\n+    async sendJson(data) {\n+        await this.connect().then(api => {\n+            api.ws.send(JSON.stringify(data));\n+        });\n+    }\n+\n+    async call(method, ...args) {\n+        const call = {\n+            callId: this.generateCallId(),\n+            method,\n+            args,\n+        };\n+        const me = this\n+        return new Promise((resolve) => {\n+            me.addEventListener(call.callId, data => resolve(data));\n+            me.sendJson(call);\n+        });\n+    }\n+}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-16", "line": "refactor: Standardized import statements and whitespace.", "commit": "c9c070c497bb9af3eb5bb9915f221ec00b56b832", "diff": "commit c9c070c497bb9af3eb5bb9915f221ec00b56b832\nAuthor: BordedDev <>\nDate:   Sun Mar 16 05:03:05 2025 +0100\n\n    Reformated file\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex e4a59ea..95e5bc4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,9 +7,9 @@\n \n \n-import {Schedule} from './schedule.js';\n-import {EventHandler} from \"./event-handler.js\";\n-import {Socket} from \"./socket.js\";\n+import { Schedule } from './schedule.js';\n+import { EventHandler } from \"./event-handler.js\";\n+import { Socket } from \"./socket.js\";\n \n export class RESTClient {\n     debug = false;\n@@ -25,7 +25,7 @@ export class RESTClient {\n         });\n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({url, params, result});\n+            console.debug({ url, params, result });\n         }\n         return result;\n     }\n@@ -41,7 +41,7 @@ export class RESTClient {\n \n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({url, data, result});\n+            console.debug({ url, data, result });\n         }\n         return result;\n     }\n@@ -87,7 +87,7 @@ export class Chat extends EventHandler {\n     call(method, ...args) {\n         return new Promise((resolve, reject) => {\n             try {\n-                const command = {method, args, message_id: this.generateUniqueId()};\n+                const command = { method, args, message_id: this.generateUniqueId() };\n                 this._promises[command.message_id] = resolve;\n                 this._socket.send(JSON.stringify(command));\n             } catch (e) {"}
{"repo": ".", "date": "2025-03-16", "line": "Merge: Improved code review process", "commit": "e62a8554090009c7914b95833066ad46251da01d", "diff": "commit e62a8554090009c7914b95833066ad46251da01d\nMerge: a9663c8 c9c070c\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 16 05:02:03 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted upload button background color", "commit": "819cf8381c287e2ee88fb1d6fe789e30a1a33eff", "diff": "commit 819cf8381c287e2ee88fb1d6fe789e30a1a33eff\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 06:42:03 2025 +0100\n\n    Change color.\n\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex c399d2b..0a84705 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -60,7 +60,7 @@ class UploadButtonElement extends HTMLElement {\n                 justify-content: center;\n                 align-items: center;\n                 height: 100vh;\n             }\n             .upload-container {\n                 position: relative;\n@@ -70,7 +70,7 @@ class UploadButtonElement extends HTMLElement {\n                 align-items: center;\n                 justify-content: center;\n                 padding: 10px 20px;\n                 color: white;\n                 border: none;\n                 border-radius: 5px;\n@@ -87,7 +87,7 @@ class UploadButtonElement extends HTMLElement {\n                 left: 0;\n                 top: 0;\n                 height: 100%;\n-                background: rgba(255, 255, 255, 0.4);\n                 width: 0%;\n             }\n             .hidden-input {"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted chat input upload button color", "commit": "2ba28c193a826e8c1f9647f06bffb6084166c9f1", "diff": "commit 2ba28c193a826e8c1f9647f06bffb6084166c9f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 06:45:31 2025 +0100\n\n    Change color.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 04610da..943e819 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -222,7 +222,6 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .chat-input upload-button {\n   color: white;\n   border: none;\n   padding: 10px 15px;"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Added basic tracker functionality and updated templates", "commit": "287e10d8aa8feb2590ad48d9412ace74a9432baf", "diff": "commit 287e10d8aa8feb2590ad48d9412ace74a9432baf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 19:46:15 2025 +0100\n\n    Added tracker.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 5e30845..31f5d7f 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -17,6 +17,7 @@\n   <link rel=\"stylesheet\" href=\"/base.css\">\n \n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\n <body>\n   <header>\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex a7cc1c5..df0796c 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -20,7 +20,7 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n-\n     {% block head %}\n     {% endblock %}\n </head>\n@@ -35,4 +35,4 @@\n     {% endblock %}\n </main>\n </body>\n-</html>\n\\ No newline at end of file\n+</html>\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 1894bc4..ffbd5bc 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -7,6 +7,7 @@\n     <link rel=\"stylesheet\" href=\"generic-form.css\">\n   <link rel=\"stylesheet\" href=\"register__.css\">\n     <script src=\"/fancy-button.js\"></script>\n </head>\n <body>\n   <div class=\"registration-container\">"}
{"repo": ".", "date": "2025-03-17", "line": "fix: Scroll to the end of the message container", "commit": "54416ee84f88064897a824ae2c3a9e0ef2c1ccaa", "diff": "commit 54416ee84f88064897a824ae2c3a9e0ef2c1ccaa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 17 09:15:49 2025 +0100\n\n    Added block end.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e1e443b..932a1fa 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -138,7 +138,7 @@\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n-            lastMessage?.scrollIntoView({ inline: \"nearest\" });\n+            lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Refactor header layout and logo display", "commit": "39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6", "diff": "commit 39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 17 17:01:01 2025 +0100\n\n    Layout update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 943e819..0b475d1 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -39,16 +39,19 @@ main {\n \n header {\n-  padding: 10px 20px;\n+  padding-top: 10px;\n+  padding-left: 20px;\n+  padding-right: 20px;\n   display: flex;\n   justify-content: space-between;\n   align-items: center;\n }\n \n header .logo {\n-  font-size: 1.5em;\n   font-weight: bold;\n+ font-size: 1.2em;\n }\n \n header nav a {\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 31f5d7f..a8bb473 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -21,7 +21,9 @@\n </head>\n <body>\n   <header>\n-    <div class=\"no-select logo\">Snek</div>\n+    <div class=\"no-select logo\" style=\"display:none\">Snek</div>\n+    \n+    <div class=\"logo\">{% block header_text %}{% endblock %}</div>\n     <nav class=\"no-select\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n@@ -30,6 +32,7 @@\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n+\n   </header>\n   <main>\n     {% block sidebar %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 932a1fa..8a68f64 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,10 +1,9 @@\n {% extends \"app.html\" %}\n \n+{% block header_text %}<h2>{{ name }}</h2>{% endblock %} \n+\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\">\n-        <h2>{{ name }}</h2>\n-    </div>\n     <div class=\"chat-messages\">\n         {% for message in messages %}\n             {% autoescape false %}"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill and update templates", "commit": "7fa2817f773b47737236f4bb700023bcf8b483f1", "diff": "commit 7fa2817f773b47737236f4bb700023bcf8b483f1\nAuthor: BordedDev <>\nDate:   Mon Mar 17 22:05:46 2025 +0100\n\n    Added Promise.withResolvers pollyfill\n\ndiff --git a/src/snek/static/polyfills/Promise.withResolvers.js b/src/snek/static/polyfills/Promise.withResolvers.js\nnew file mode 100644\nindex 0000000..03f0185\n--- /dev/null\n+++ b/src/snek/static/polyfills/Promise.withResolvers.js\n@@ -0,0 +1,8 @@\n+Promise.withResolvers = Promise.withResolvers || function() {\n+    let resolve, reject;\n+    let promise = new Promise((res, rej) => {\n+        resolve = res;\n+        reject = rej;\n+    });\n+    return { promise, resolve, reject };\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 31f5d7f..2d96495 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -6,6 +6,7 @@\n     <link rel=\"manifest\" href=\"/manifest.json\" />\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <script src=\"/polyfills/Promise.withResolvers.js\" type=\"module\"></script>\n   <!-- \n   <script src=\"/push.js\"></script>\n   -->\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex df0796c..d5df6eb 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -12,7 +12,8 @@\n \n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n \n-    <script src=\"/app.js\"  type=\"module\"></script>\n+    <script src=\"/polyfills/Promise.withResolvers.js\" type=\"module\"></script>\n+    <script src=\"/app.js\" type=\"module\"></script>\n     <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n     <link rel=\"stylesheet\" href=\"/style.css\">\n@@ -20,7 +21,8 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n+            data-website-id=\"d127c3e4-dc70-4041-a1c8-bcc32c2492ea\"></script>\n     {% block head %}\n     {% endblock %}\n </head>"}
{"repo": ".", "date": "2025-03-17", "line": "revert: Removed unnecessary website ID attribute", "commit": "965dc930a900a5080e225bb492be2b799daed22f", "diff": "commit 965dc930a900a5080e225bb492be2b799daed22f\nAuthor: BordedDev <>\nDate:   Mon Mar 17 22:06:52 2025 +0100\n\n    Undid formatting\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d5df6eb..d2f8de7 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -21,8 +21,7 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n-            data-website-id=\"d127c3e4-dc70-4041-a1c8-bcc32c2492ea\"></script>\n     {% block head %}\n     {% endblock %}\n </head>"}
{"repo": ".", "date": "2025-03-17", "line": "Merge: Improved code formatting and added review notes.", "commit": "825ece4e7868be28ba03c4fde5149695b0dd9dc5", "diff": "commit 825ece4e7868be28ba03c4fde5149695b0dd9dc5\nMerge: 39fa8fa 965dc93\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Mon Mar 17 21:09:55 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Added dump script for public channels", "commit": "3c6a0944d68ca16250ec9364d7f006b0e7eea6e8", "diff": "commit 3c6a0944d68ca16250ec9364d7f006b0e7eea6e8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 00:46:32 2025 +0100\n\n    Added dump script.\n\ndiff --git a/Makefile b/Makefile\nindex e3febf7..70b06a0 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -8,6 +8,9 @@ PORT = 8081\n python:\n \t$(PYTHON)\n \n+dump:\n+\t$(PYTHON) -m snek.dump\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nnew file mode 100644\nindex 0000000..6401637\n--- /dev/null\n+++ b/src/snek/dump.py\n@@ -0,0 +1,17 @@\n+import json \n+\n+from snek.app import app\n+\n+\n+def dump_public_channels():\n+    result = {'channels':{}}\n+\n+    for channel in app.db['channel'].find(is_private=False,is_listed=True):\n+        result['channels'][channel['label']] = dict(channel)\n+        result['channels'][channel['label']]['messages'] = list(dict(record) for record in app.db['channel_message'].find(channel_uid=channel['uid']))\n+\n+    print(json.dumps(result, sort_keys=True, indent=4,default=str),end='',flush=True)\n+    \n+\n+if __name__ == '__main__':\n+    dump_public_channels()"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Improved dump functionality with JSON output and user information\n\n", "commit": "70db15bf27c7a8fd8bf112432f02754f01bbb3d7", "diff": "commit 70db15bf27c7a8fd8bf112432f02754f01bbb3d7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 00:13:25 2025 +0000\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex 70b06a0..62d24ca 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -9,7 +9,7 @@ python:\n \t$(PYTHON)\n \n dump:\n-\t$(PYTHON) -m snek.dump\n+\t@$(PYTHON) -m snek.dump\n \n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 6401637..cf3d417 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,17 +1,33 @@\n+import asyncio\n import json \n \n from snek.app import app\n \n+async def fix_message(message):\n+    message = dict(\n+        uid=message['uid'],\n+        user_uid=message['user_uid'],\n+        text=message['message'],\n+        sent=message['created_at']\n+    )\n+    user = await app.services.user.get(uid=message['user_uid'])\n+    message['user'] = user and user['username'] or None\n+    return message\n \n-def dump_public_channels():\n+async def dump_public_channels():\n     result = {'channels':{}}\n-\n-    for channel in app.db['channel'].find(is_private=False,is_listed=True):\n+    for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n+        print(f\"Dumping channel: {channel['label']}.\")\n         result['channels'][channel['label']] = dict(channel)\n-        result['channels'][channel['label']]['messages'] = list(dict(record) for record in app.db['channel_message'].find(channel_uid=channel['uid']))\n-\n-    print(json.dumps(result, sort_keys=True, indent=4,default=str),end='',flush=True)\n+        result['channels'][channel['label']]['messages'] = [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        print(\"Dump succesfull!\")\n+    print(\"Converting to json.\")\n+    data = json.dumps(result, indent=4,default=str)\n+    print(\"Converting succesful, now writing to dump.json\")\n+    with open(\"dump.json\",\"w\") as f:\n+        f.write(data)\n+    print(\"Dump written to dump.json\")\n     \n \n if __name__ == '__main__':\n-    dump_public_channels()\n+    asyncio.run(dump_public_channels())\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex f9c4761..cd9484d 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -20,13 +20,13 @@ class Cache:\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n-            print(\"Cache miss!\", args, flush=True)\n             return None\n         self.lru.insert(0, args)\n         while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n-        print(\"Cache hit!\", args, flush=True)\n         return self.cache[args]\n \n     def json_default(self, value):\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(f\"Cache store! {len(self.lru)} items. New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Refactor dump script to output to text file", "commit": "3960390ec45f427979ebd2b81c1a21666a47e71d", "diff": "commit 3960390ec45f427979ebd2b81c1a21666a47e71d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 21:06:28 2025 +0000\n\n    Updated dump script.\n\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex cf3d417..1b7eb6b 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -12,20 +12,18 @@ async def fix_message(message):\n     )\n     user = await app.services.user.get(uid=message['user_uid'])\n     message['user'] = user and user['username'] or None\n-    return message\n+    return (message['user'] or '') + ': ' + (message['text'] or '')\n \n async def dump_public_channels():\n-    result = {'channels':{}}\n+    result = []\n     for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n         print(f\"Dumping channel: {channel['label']}.\")\n-        result['channels'][channel['label']] = dict(channel)\n-        result['channels'][channel['label']]['messages'] = [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        result += [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n         print(\"Dump succesfull!\")\n     print(\"Converting to json.\")\n-    data = json.dumps(result, indent=4,default=str)\n     print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.json\",\"w\") as f:\n-        f.write(data)\n+    with open(\"dump.txt\",\"w\") as f:\n+        f.write('\\n\\n'.join(result))\n     print(\"Dump written to dump.json\")"}
{"repo": ".", "date": "2025-03-20", "line": "refactor: Update session management for user registration", "commit": "5ba239caa8928a078362af0e6e2d1a4626bd508d", "diff": "commit 5ba239caa8928a078362af0e6e2d1a4626bd508d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 20 02:12:00 2025 +0100\n\n    Changes by r.\n\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 6d6d6ad..5028a7a 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -31,4 +31,4 @@ class LoginView(BaseFormView):\n                 \"color\": user[\"color\"]\n             })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex db812b5..6e49506 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -24,8 +24,10 @@ class RegisterView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"username\"]\n-        self.request.session[\"logged_in\"] = True\n-        self.request.session[\"color\"] = result[\"color\"]\n+        self.request.session.update({\n+            \"uid\": result[\"uid\"],\n+            \"username\": result[\"username\"],\n+            \"logged_in\": True,\n+            \"color\": result[\"color\"]\n+        })\n         return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 858edad..5ce42a7 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -14,21 +14,20 @@\n \n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n-\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n@@ -36,8 +35,10 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"username\"]\n-        self.request.session[\"logged_in\"] = True\n-\n+        self.request.session.update({\n+            \"uid\": result[\"uid\"],\n+            \"username\": result[\"username\"],\n+            \"logged_in\": True,\n+            \"color\": result[\"color\"]\n+        })\n         return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 9428f08..117942a 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -19,11 +19,10 @@\n \n-\n from snek.system.view import BaseView\n \n class StatusView(BaseView):"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and updated Dockerfile and compose.yml", "commit": "7dcabde2ed0ab699ea3033f7788381e85c352b97", "diff": "commit 7dcabde2ed0ab699ea3033f7788381e85c352b97\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 18:16:36 2025 +0100\n\n    Update.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex ffdc3d7..6ef9f83 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -13,6 +13,7 @@ RUN apk add --no-cache \\\n     libxext \\\n     libssl3 \\\n     ca-certificates \\\n+    docker \\\n     fontconfig \\\n     freetype \\\n     ttf-dejavu \\\ndiff --git a/compose.yml b/compose.yml\nindex 17bcf22..70d21ba 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -2,10 +2,14 @@ services:\n   snek:\n     build: .\n     restart: always\n+    privileged: true\n     ports:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+      - /media/storage/snek/molodetz.nl/drive:/code/drive\n+      - /var/run/docker.sock:/var/run/docker.sock\n+      - /media/storage/snek/molodetz.nl/drive:/drive\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e1f74e2..a7bc195 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -37,7 +37,7 @@ from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n-\n+from snek.view.terminal import TerminalView, TerminalSocketView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -115,6 +115,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/rpc.ws\", RPCView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n         self.router.add_view(\"/threads.html\", ThreadsView)\n+        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n+        self.router.add_view(\"/terminal.html\", TerminalView)\n \n         self.add_subapp(\n             \"/docs\","}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and basic shell environment.", "commit": "013d4adce57f4afec5176bbdb5e2225d529ec3b7", "diff": "commit 013d4adce57f4afec5176bbdb5e2225d529ec3b7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 19:57:39 2025 +0100\n\n    Drive access.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex 6ef9f83..d24c47d 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -1,41 +1,10 @@\n-FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n-FROM python:3.12.8-alpine3.21\n+FROM python:3.14.0a6-bookworm\n+RUN mkdir -p /code \n WORKDIR /code\n-ENV FLASK_APP=app.py\n-ENV FLASK_RUN_HOST=0.0.0.0\n-RUN apk add --no-cache gcc musl-dev linux-headers git\n-\n-RUN apk add --no-cache \\\n-    libstdc++ \\\n-    libx11 \\\n-    libxrender \\\n-    libxext \\\n-    libssl3 \\\n-    ca-certificates \\\n-    docker \\\n-    fontconfig \\\n-    freetype \\\n-    ttf-dejavu \\\n-    ttf-droid \\\n-    ttf-freefont \\\n-    ttf-liberation \\\n-  && apk add --no-cache --virtual .build-deps \\\n-    msttcorefonts-installer \\\n-  && update-ms-fonts \\\n-  && fc-cache -f \\\n-  && apk del .build-deps\n-COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n-COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n+RUN apt update && apt install build-essential docker -y     \n COPY pyproject.toml pyproject.toml \n COPY src src\n+RUN mkdir /drive\n RUN pip install --upgrade pip\n RUN pip install -e .\n-EXPOSE 8081\n \n-CMD [\"python\",\"-m\",\"snek.app\"]\ndiff --git a/compose.yml b/compose.yml\nindex 70d21ba..9d55d3b 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -7,26 +7,12 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-      - /media/storage/snek/molodetz.nl/drive:/code/drive\n+      - /media/storage/snek.molodetz.nl/drive:/code/drive\n+      - /media/storage/snek.molodetz.nl/drive:/drive\n       - /var/run/docker.sock:/var/run/docker.sock\n-      - /media/storage/snek/molodetz.nl/drive:/drive\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n-  snecssh:\n-    build:\n-      context: .\n-      dockerfile: DockerfileDrive\n-    restart: always\n-    ports:\n-      - \"2225:2225\"\n-    volumes:\n-      - ./:/code\n-    environment:\n-     - PYTHONDONTWRITEBYTECODE=\"1\"\n-     - PYTHONUNBUFFERED=\"1\"\n-    entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]\n    \ndiff --git a/src/snek/scripts/chat.js b/src/snek/scripts/chat.js\nnew file mode 100644\nindex 0000000..a4d6782\n--- /dev/null\n+++ b/src/snek/scripts/chat.js\n@@ -0,0 +1,54 @@\n+const channelUid = \"{{ channel.uid.value }}\";\n+\n+function initInputField(textBox) {\n+    textBox.addEventListener('change', (e) => {\n+        e.preventDefault();\n+        this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+    });\n+\n+    textBox.addEventListener('keydown', (e) => {\n+        if (e.key === 'Enter' && !e.shiftKey) {\n+            e.preventDefault();\n+            const message = e.target.value.trim();\n+            if (message) {\n+                app.rpc.sendMessage(channelUid, message);\n+                e.target.value = '';\n+            }\n+        }\n+    });\n+    textBox.focus();\n+}\n+\n+function updateTimes() {\n+    document.querySelectorAll(\".time\").forEach((time) => {\n+        time.innerText = app.timeDescription(time.dataset.created_at);\n+    });\n+}\n+\n+function isElementVisible(element) {\n+    const rect = element.getBoundingClientRect();\n+    return (\n+        rect.top >= 0 &&\n+        rect.left >= 0 &&\n+        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+        rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+    );\n+}\n+\n+const messagesContainer = document.querySelector(\".chat-messages\");\n+\n+let isLoadingExtra = false;\n+\n+messagesContainer.addEventListener(\"scroll\", () => {\n+    loadExtra();\n+});\n+\n+setInterval(updateTimes, 1000);\n+\n+app.addEventListener(\"channel-message\", (data) => {\n+    if (data.channel_uid !== channelUid) {\n+        if(!isMentionForSomeoneElse(data.message)){\n+            channelSidebar.notify(data);\n+        }\n+    }\n+});\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nnew file mode 100644\nindex 0000000..2d9341e\n--- /dev/null\n+++ b/src/snek/system/terminal.py\n@@ -0,0 +1,48 @@\n+import asyncio\n+import aiohttp\n+import aiohttp.web\n+import os\n+import pty\n+import shlex\n+import subprocess\n+import pathlib\n+\n+commands = {\n+    'alpine': 'docker run -it alpine /bin/sh',\n+    'r': 'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh',\n+}\n+\n+class TerminalSession:\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.process = subprocess.Popen(\n+            command.split(\" \"),\n+            stdin=self.slave,\n+            stdout=self.slave,\n+            stderr=self.slave,\n+            bufsize=0,\n+            universal_newlines=True\n+        )\n+\n+    async def read_output(self, ws):\n+        loop = asyncio.get_event_loop()\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1:\n+            return \n+        while True:\n+            try:\n+                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n+                if not data:\n+                    break\n+                try:\n+                except:\n+                    self.sockets.remove(ws)\n+            except Exception:\n+                break\n+\n+    async def write_input(self, data):\n+        os.write(self.master, data.encode())\n+\n+\ndiff --git a/src/snek/templates/static b/src/snek/templates/static\nnew file mode 120000\nindex 0000000..d9bc54d\n--- /dev/null\n+++ b/src/snek/templates/static\n@@ -0,0 +1 @@\n+../static/\n\\ No newline at end of file\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nnew file mode 100644\nindex 0000000..d9ad6d1\n--- /dev/null\n+++ b/src/snek/templates/terminal.html\n@@ -0,0 +1,47 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+Reboot\n+{% endblock %}\n+\n+{% block main %}\n+    <style>\n+    </style>\n+\n+  <div class=\"container\" id=\"terminal\"></div>\n+\n+    <script>\n+        const term = new Terminal({ cursorBlink: true });\n+        const fitAddon = new FitAddon.FitAddon();\n+        term.loadAddon(fitAddon);\n+        term.open(document.getElementById(\"terminal\"));\n+        fitAddon.fit();\n+\n+        window.addEventListener(\"resize\", () => fitAddon.fit());\n+        \n+        const schema = window.location.protocol === \"https:\" ? \"wss\" : \"ws\";\n+        const hostname = window.location.host;\n+\n+        const socket = new WebSocket(url);\n+\n+        socket.onopen = () => term.write(\"\\x1b[32mConnected to Molodetz\\x1b[0m\\r\\n\");\n+\n+        socket.onmessage = (event) => {\n+            const data = new Uint8Array(event.data);\n+            term.write(new TextDecoder().decode(data));\n+        };\n+\n+        term.onData(data => socket.send(new TextEncoder().encode(data)));\n+\n+        socket.onclose = () => term.write(\"\\r\\n\\x1b[31mConnection closed\\x1b[0m\\r\\n\");\n+    </script>\n+\n+\n+\n+{% endblock main %}\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nnew file mode 100644\nindex 0000000..e54dfb7\n--- /dev/null\n+++ b/src/snek/view/terminal.py\n@@ -0,0 +1,56 @@\n+from snek.system.view import BaseView \n+import aiohttp \n+import asyncio\n+from snek.system.terminal import TerminalSession\n+import pathlib\n+\n+class TerminalSocketView(BaseView):\n+    \n+    login_required = True\n+\n+    user_sessions = {}\n+    \n+    async def prepare_drive(self):\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n+        root.mkdir(parents=True, exist_ok=True)\n+        terminal_folder = pathlib.Path(\"terminal\")\n+        for path in terminal_folder.iterdir():\n+            destination_path = root.joinpath(path.name)\n+            if not destination_path.exists():\n+                if not path.is_dir():\n+                    destination_path.write_bytes(path.read_bytes())\n+        return root \n+        \n+    async def get(self):\n+       \n+\n+\n+        ws = aiohttp.web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = await self.prepare_drive()\n+        \n+        command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n+        print(command)\n+\n+        session = self.user_sessions.get(user[\"uid\"])\n+        if not session:\n+            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n+        session = self.user_sessions[user[\"uid\"]]   \n+        asyncio.create_task(session.read_output(ws))  \n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.BINARY:\n+                await session.write_input(msg.data.decode())\n+\n+        \n+        return ws\n+\n+class TerminalView(BaseView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        request = self.request\n+        return await self.request.app.render_template('terminal.html',self.request)\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nnew file mode 100644\nindex 0000000..2b5d791\n--- /dev/null\n+++ b/terminal/.bashrc\n@@ -0,0 +1,108 @@\n+\n+[ -z \"$PS1\" ] && return\n+\n+HISTCONTROL=ignoredups:ignorespace\n+\n+shopt -s histappend\n+\n+HISTSIZE=1000\n+HISTFILESIZE=2000\n+\n+shopt -s checkwinsize\n+\n+[ -x /usr/bin/lesspipe ] && eval \"$(SHELL=/bin/sh lesspipe)\"\n+\n+if [ -z \"$debian_chroot\" ] && [ -r /etc/debian_chroot ]; then\n+    debian_chroot=$(cat /etc/debian_chroot)\n+fi\n+\n+case \"$TERM\" in\n+    xterm-color) color_prompt=yes;;\n+esac\n+\n+\n+if [ -n \"$force_color_prompt\" ]; then\n+    if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then\n+\tcolor_prompt=yes\n+    else\n+\tcolor_prompt=\n+    fi\n+fi\n+\n+if [ \"$color_prompt\" = yes ]; then\n+    PS1='${debian_chroot:+($debian_chroot)}\\[\\033[01;32m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]\\$ '\n+else\n+    PS1='${debian_chroot:+($debian_chroot)}\\u@\\h:\\w\\$ '\n+fi\n+unset color_prompt force_color_prompt\n+\n+case \"$TERM\" in\n+xterm*|rxvt*)\n+    PS1=\"\\[\\e]0;${debian_chroot:+($debian_chroot)}\\u@\\h: \\w\\a\\]$PS1\"\n+    ;;\n+*)\n+    ;;\n+esac\n+\n+if [ -x /usr/bin/dircolors ]; then\n+    test -r ~/.dircolors && eval \"$(dircolors -b ~/.dircolors)\" || eval \"$(dircolors -b)\"\n+    alias ls='ls --color=auto'\n+\n+    alias grep='grep --color=auto'\n+    alias fgrep='fgrep --color=auto'\n+    alias egrep='egrep --color=auto'\n+fi\n+\n+alias ll='ls -alF'\n+alias la='ls -A'\n+alias l='ls -CF'\n+\n+\n+if [ -f ~/.bash_aliases ]; then\n+    . ~/.bash_aliases\n+fi\n+\n+\n+cp ~/r /usr/local/bin \n+\n+\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv -y\n+\n+echo \"r is installed.\"\n+\ndiff --git a/terminal/.profile b/terminal/.profile\nnew file mode 100644\nindex 0000000..c4c7402\n--- /dev/null\n+++ b/terminal/.profile\n@@ -0,0 +1,9 @@\n+\n+if [ \"$BASH\" ]; then\n+  if [ -f ~/.bashrc ]; then\n+    . ~/.bashrc\n+  fi\n+fi\n+\n+mesg n 2> /dev/null || true\ndiff --git a/terminal/r b/terminal/r\nnew file mode 100755\nindex 0000000..2cc65df\nBinary files /dev/null and b/terminal/r differ"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal page with Ubuntu option and channel sidebar", "commit": "6e68408ddfd8be2376f7453deac8f63a6bfb93e4", "diff": "commit 6e68408ddfd8be2376f7453deac8f63a6bfb93e4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:04:20 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 9dd20b5..2d034b7 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,6 +4,11 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2 class=\"no-select\">Terminals</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n+    </ul>\n+    {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n       {% for channel in channels if not channel['is_private'] %}\n@@ -16,6 +21,7 @@\n       <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n       {% endfor %}\n     </ul>\n+    {% endif %}\n   </aside>\n   <script>\n     class ChannelSidebar {\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex d9ad6d1..f1d4e14 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -1,7 +1,9 @@\n {% extends \"app.html\" %}\n \n {% block sidebar %}\n-Reboot\n+\n+{% include \"sidebar_channels.html\" %}\n+\n {% endblock %}\n \n {% block main %}"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Add channel list and user context to template rendering", "commit": "604e27ce10dee59b1b3f6ebd359ee356b085df2a", "diff": "commit 604e27ce10dee59b1b3f6ebd359ee356b085df2a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:42:38 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a7bc195..81259bc 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -143,6 +143,31 @@ class Application(BaseApplication):\n \n     async def render_template(self, template, request, context=None):\n+        channels = []\n+        if not context:\n+            context = {}\n+        if request.session.get(\"uid\"): \n+            async for subscribed_channel in self.services.channel_member.find(user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                item = {}\n+                other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n+                parent_object = await subscribed_channel.get_channel()\n+                last_message =await parent_object.get_last_message()\n+                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n+                if other_user:\n+                    item[\"name\"] = other_user[\"nick\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                else:\n+                    item[\"name\"] = subscribed_channel[\"label\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                channels.append(item)\n+            \n+            channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n+            if not 'channels' in context:\n+                context['channels'] = channels\n+            if not 'user' in context:\n+                context['user'] = await self.services.user.get(uid=request.session.get(\"uid\"))\n+\n         return await super().render_template(template, request, context)\n \n \ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex e54dfb7..8af82e7 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -17,9 +17,8 @@ class TerminalSocketView(BaseView):\n         terminal_folder = pathlib.Path(\"terminal\")\n         for path in terminal_folder.iterdir():\n             destination_path = root.joinpath(path.name)\n-            if not destination_path.exists():\n-                if not path.is_dir():\n-                    destination_path.write_bytes(path.read_bytes())\n+            if not path.is_dir():\n+                destination_path.write_bytes(path.read_bytes())\n         return root \n         \n     async def get(self):\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 2b5d791..d8b23c1 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,11 +94,11 @@ fi\n \n cp ~/r /usr/local/bin \n \n+chmod -x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y\n \n-echo \"r is installed.\"\n+echo \"R is installed. Type r to run it.\"\n \n@@ -106,3 +106,4 @@ echo \"r is installed.\"\n+export PS1=\"root@snek: \""}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c72b015073347e86f2edf1e67544b1ae31e929b0", "diff": "commit c72b015073347e86f2edf1e67544b1ae31e929b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:55:57 2025 +0100\n\n    Update .bashrc\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex d8b23c1..0135ff9 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,7 +94,7 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod -x /usr/local/bin/r\n+chmod +x /usr/local/bin/r\n \n apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Disable r executable and add vim and htop to apt install", "commit": "78c631e6c79b4b69b0e202a301b710c4150e6dbe", "diff": "commit 78c631e6c79b4b69b0e202a301b710c4150e6dbe\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 21:01:15 2025 +0100\n\n    Updated .bashrc.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 0135ff9..d215a7b 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,9 +94,9 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod +x /usr/local/bin/r\n+chmod -x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y\n \n echo \"R is installed. Type r to run it.\""}
{"repo": ".", "date": "2025-03-22", "line": "fix: Make r executable", "commit": "c8461342fd8d260da69c84f27f9e9d13b3430942", "diff": "commit c8461342fd8d260da69c84f27f9e9d13b3430942\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 22:46:19 2025 +0100\n\n    Update.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex d215a7b..b52a4d0 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,7 +94,7 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod -x /usr/local/bin/r\n+chmod +x /usr/local/bin/r\n \n apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Introduce ThreadPoolExecutor for asynchronous task handling", "commit": "0bc24e8d2ef4452efc7be5286288a2531908ea55", "diff": "commit 0bc24e8d2ef4452efc7be5286288a2531908ea55\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 02:16:24 2025 +0100\n\n    New executor.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 81259bc..eb26333 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,7 +38,7 @@ from snek.view.search_user import SearchUserView\n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n from snek.view.terminal import TerminalView, TerminalSocketView\n-\n+from  concurrent.futures import ThreadPoolExecutor\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -170,7 +170,10 @@ class Application(BaseApplication):\n \n         return await super().render_template(template, request, context)\n \n+executor = ThreadPoolExecutor(max_workers=100)\n \n+loop = asyncio.get_event_loop()\n+loop.set_default_executor(executor)\n "}
{"repo": ".", "date": "2025-03-23", "line": "feat: Improve terminal handling and buffer management", "commit": "c2d9af807a95900c4fecd7a1929c8d92393a955d", "diff": "commit c2d9af807a95900c4fecd7a1929c8d92393a955d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 02:53:36 2025 +0100\n\n    Terminal changes.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 2d9341e..0b012ba 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -16,6 +16,7 @@ class TerminalSession:\n     def __init__(self,command):\n         self.master, self.slave = pty.openpty()\n         self.sockets =[]\n+        self.buffer = b''\n         self.process = subprocess.Popen(\n             command.split(\" \"),\n             stdin=self.slave,\n@@ -29,17 +30,30 @@ class TerminalSession:\n         loop = asyncio.get_event_loop()\n         self.sockets.append(ws)\n         if len(self.sockets) > 1:\n+            start = self.buffer.index(b'\\n')\n+            await ws.send_bytes(self.buffer[start:])\n             return \n         while True:\n             try:\n                 data = await loop.run_in_executor(None, os.read, self.master, 1024)\n                 if not data:\n                     break\n+                self.buffer += data\n+                if len(self.buffer) > 10000:\n+                    self.buffer = self.buffer[:-10000]\n                 try:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\n+                print(\"Terminating process\")\n+                self.process.terminate()\n+                print(\"Terminated process\")\n+                for ws in self.sockets:\n+                    try:\n+                        await ws.close()\n+                    except Exception:\n+                        pass\n                 break\n \n     async def write_input(self, data):\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex f1d4e14..335a85f 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -11,7 +11,8 @@\n     <style>\n     </style>\n \n   <div class=\"container\" id=\"terminal\"></div>"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Install git alongside dependencies", "commit": "c5c160baae67d7e5932963f8501ed7d56dc35c21", "diff": "commit c5c160baae67d7e5932963f8501ed7d56dc35c21\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 03:16:43 2025 +0100\n\n    Updated bsahrc.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex b52a4d0..e4022a9 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -96,7 +96,7 @@ cp ~/r /usr/local/bin\n \n chmod +x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git -y\n \n echo \"R is installed. Type r to run it.\""}
{"repo": ".", "date": "2025-03-23", "line": "feat: Added drive functionality with views and models", "commit": "7b32a7eba4d5944142a3b40616d37b0862087371", "diff": "commit 7b32a7eba4d5944142a3b40616d37b0862087371\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 13:57:20 2025 +0100\n\n    Update drive.\n\ndiff --git a/Makefile b/Makefile\nindex 62d24ca..c9a7a28 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -15,7 +15,7 @@ run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\n install:\n-\tpython3 -m venv .venv \n+\tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n \n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex eb26333..daefbd0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,6 +38,7 @@ from snek.view.search_user import SearchUserView\n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n from snek.view.terminal import TerminalView, TerminalSocketView\n+from snek.view.drive import DriveView\n from  concurrent.futures import ThreadPoolExecutor\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -67,6 +68,13 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n+       \n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_database)\n+\n+    async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n \n@@ -79,10 +87,8 @@ class Application(BaseApplication):\n                 self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n         except:\n             pass \n-        \n-        self.cache = Cache(self)\n-        self.services = get_services(app=self)\n-        self.mappers = get_mappers(app=self)\n+ \n+        await app.services.drive.prepare_all()\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n@@ -117,6 +123,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n         self.router.add_view(\"/terminal.html\", TerminalView)\n+        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n \n         self.add_subapp(\n             \"/docs\",\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex a310bbd..3936d97 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -4,4 +4,10 @@ from snek.system.model import BaseModel,ModelField\n class DriveModel(BaseModel):\n \n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    \n+    name = ModelField(name='name', required=False, type=str) \n+\n+    @property\n+    async def items(self):\n+        async for drive_item in self.app.services.drive_item.find(drive_uid=self['uid']):\n+            yield drive_item\n+\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 74b8deb..728cd89 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,5 +1,5 @@\n from snek.system.model import BaseModel,ModelField \n-\n+import mimetypes\n \n class DriveItemModel(BaseModel):\n     drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n@@ -7,3 +7,12 @@ class DriveItemModel(BaseModel):\n     path = ModelField(name=\"path\", required=True,kind=str)\n     file_type = ModelField(name=\"file_type\", required=True,kind=str) \n     file_size = ModelField(name=\"file_size\", required=True,kind=int)\n+    \n+    @property\n+    def extension(self):\n+        return self['name'].split('.')[-1]\n+    \n+    @property \n+    def mime_type(self):\n+        mimetype,_ = mimetypes.guess_type(self['name'])\n+        return mimetype \ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex 9d409a8..b90a959 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -5,17 +5,75 @@ class DriveService(BaseService):\n     \n     mapper_name = \"drive\"\n \n-    async def get_by_user(self, user_uid):\n-        drives = [] \n-        async for model in self.find(user_uid=user_uid):\n-            drives.append(model)\n-        return drives \n-\n-    async def get_or_create(self, user_uid):\n-        drives = await self.get_by_user(user_uid=user_uid)\n-        if len(drives) == 0:\n-            model = await self.new()\n-            model['user_uid'] = user_uid  \n-            await self.save(model)\n-            return model \n-        return drives[0]\n+    EXTENSIONS_PICTURES = [\"jpg\",\"jpeg\",\"png\",\"gif\",\"svg\",\"webp\",\"tiff\"]\n+    EXTENSIONS_VIDEOS = [\"mp4\",\"m4v\",\"mov\",\"wmv\",\"webm\",\"mkv\",\"mpg\",\"mpeg\",\"avi\",\"ogv\",\"ogg\",\"flv\",\"3gp\",\"3g2\"]\n+    EXTENSIONS_ARCHIVES = [\"zip\",\"rar\",\"7z\",\"tar\",\"tar.gz\",\"tar.xz\",\"tar.bz2\",\"tar.lzma\",\"tar.lz\"]\n+    EXTENSIONS_AUDIO = [\"mp3\",\"wav\",\"ogg\",\"flac\",\"m4a\",\"wma\",\"aac\",\"opus\",\"aiff\",\"au\",\"mid\",\"midi\"]\n+    EXTENSIONS_DOCS = [\"pdf\",\"doc\",\"docx\",\"xls\",\"xlsx\",\"ppt\",\"pptx\",\"txt\",\"md\",\"json\",\"csv\",\"xml\",\"html\",\"css\",\"js\",\"py\",\"sql\",\"rs\",\"toml\",\"yml\",\"yaml\",\"ini\",\"conf\",\"config\",\"log\",\"csv\",\"tsv\",\"java\",\"cs\",\"csproj\",\"scss\",\"less\",\"sass\",\"json\",\"lock\",\"lock.json\",\"jsonl\"]\n+\n+    async def get_drive_name_by_extension(self, extension):\n+        if extension.startswith(\".\"):\n+            extension = extension[1:]\n+        if extension in self.EXTENSIONS_PICTURES:\n+            return \"Pictures\"\n+        if extension in self.EXTENSIONS_VIDEOS:\n+            return \"Videos\"\n+        if extension in self.EXTENSIONS_ARCHIVES:\n+            return \"Archives\"\n+        if extension in self.EXTENSIONS_AUDIO:\n+            return \"Audio\"\n+        if extension in self.EXTENSIONS_DOCS:\n+            return \"Documents\"\n+        return \"My Drive\"\n+\n+    async def get_drive_by_extension(self,user_uid, extension):\n+        name = await self.get_drive_name_by_extension(extension)\n+        return await self.get_or_create(user_uid=user_uid,name=name)\n+\n+    async def get_by_user(self, user_uid,name=None):\n+        kwargs = dict(\n+            user_uid = user_uid\n+        )\n+        async for model in self.find(**kwargs):\n+            if not name:\n+                yield model \n+            elif model['name'] == name:\n+                yield model\n+            elif not model['name'] and name == 'My Drive':\n+                model['name'] = 'My Drive'\n+                await self.save(model)\n+                yield model \n+\n+    async def get_or_create(self, user_uid,name=None,extensions=None):\n+        kwargs = dict(user_uid=user_uid)\n+        if name:\n+            kwargs['name'] = name\n+        async for model in self.get_by_user(**kwargs):\n+            return model  \n+\n+        model = await self.new()\n+        model['user_uid'] = user_uid\n+        model['name'] = name \n+        await self.save(model)\n+        return model \n+\n+    async def prepare_default_drives(self):\n+        async for drive_item in self.services.drive_item.find():\n+            extension = drive_item.extension\n+            drive = await self.get_drive_by_extension(drive_item['user_uid'],extension)\n+            if not drive_item['drive_uid'] == drive['uid']:\n+                drive_item['drive_uid'] = drive['uid']\n+                await self.services.drive_item.save(drive_item)\n+            \n+    async def prepare_default_drives_for_user(self, user_uid):\n+        await self.get_or_create(user_uid=user_uid,name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Documents\")\n+\n+    async def prepare_all(self):\n+        await self.prepare_default_drives()\n+        async for user in self.services.user.find():\n+            await self.prepare_default_drives_for_user(user['uid']) \ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 058f55e..05a7da8 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -10,6 +10,7 @@ class DriveItemService(BaseService):\n         model['drive_uid'] = drive_uid \n         model['name'] = name \n         model['path'] = str(path) \n+        model['extension'] = str(name).split(\".\")[-1]\n         model['file_type'] = type_ \n         model['file_size'] = size\n         if await self.save(model):\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 0b012ba..3495bef 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -13,20 +13,66 @@ commands = {\n }\n \n class TerminalSession:\n-    def __init__(self,command):\n-        self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.buffer = b''\n-        self.process = subprocess.Popen(\n-            command.split(\" \"),\n+    \n+    async def ensure_process(self):\n+        if self.process:\n+            return\n+        self.process = await asyncio.create_subprocess_exec(\n+            *self.command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n-            stderr=self.slave,\n-            bufsize=0,\n+            stderr=self.slave,bufsize=0,\n             universal_newlines=True\n         )\n \n+\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.buffer = b''\n+        self.process = None \n+\n+\n+\n     async def read_output(self, ws):\n+        await self.ensure_process()\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1:\n+            start = self.buffer.index(b'\\n')\n+            await ws.send_bytes(self.buffer[start:])\n+            return \n+        while True:\n+            try:\n+                async for data in self.process.stdout:\n+                    if not data:\n+                        break\n+                    self.buffer += data\n+                    if len(self.buffer) > 10000:\n+                        self.buffer = self.buffer[:-10000]\n+                    try:\n+                    except:\n+                        self.sockets.remove(ws)\n+            except:\n+                print(\"Terminating process\")\n+                self.process.terminate()\n+                print(\"Terminated process\")\n+                for ws in self.sockets:\n+                    try:\n+                        await ws.close()\n+                    except Exception:\n+                        pass\n+                break\n+\n+    async def read_outputa(self, ws):\n         loop = asyncio.get_event_loop()\n         self.sockets.append(ws)\n         if len(self.sockets) > 1:\n@@ -57,6 +103,7 @@ class TerminalSession:\n                 break\n \n     async def write_input(self, data):\n+        await self.ensure_process()\n         os.write(self.master, data.encode())\n \n \ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nnew file mode 100644\nindex 0000000..3e10c90\n--- /dev/null\n+++ b/src/snek/view/drive.py\n@@ -0,0 +1,30 @@\n+from snek.system.view import BaseView\n+from aiohttp import web\n+\n+class DriveView(BaseView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        drive_uid = self.request.match_info.get(\"drive\")\n+\n+        if drive_uid:\n+            drive = await self.services.drive.get(uid=drive_uid)\n+            drive_items = []\n+            async for item in drive.items:\n+                drive_items.append(item.record)\n+            return web.json_response(drive_items)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        \n+        drives = []\n+        async for drive in self.services.drive.get_by_user(user['uid']):\n+            record = drive.record\n+            record['items'] = []\n+            async for item in drive.items:\n+                record['items'].append(item.record)\n+            drives.append(record)\n+        \n+        return web.json_response(drives)"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Add URL to drive items", "commit": "dec2281ac88d151afda016fc01e833dc2f0aa89e", "diff": "commit dec2281ac88d151afda016fc01e833dc2f0aa89e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 14:08:10 2025 +0100\n\n    Update drive.\n\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 3e10c90..1f159fb 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -13,7 +13,9 @@ class DriveView(BaseView):\n             drive = await self.services.drive.get(uid=drive_uid)\n             drive_items = []\n             async for item in drive.items:\n-                drive_items.append(item.record)\n+                record = item.record\n+                record['url'] = '/drive.bin/' + record['uid'] + '.' + item.extension\n+                drive_items.append(record)\n             return web.json_response(drive_items)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n@@ -24,6 +26,8 @@ class DriveView(BaseView):\n             record = drive.record\n             record['items'] = []\n             async for item in drive.items:\n+                drive_item_record = item.record\n+                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + drive_item.extension\n                 record['items'].append(item.record)\n             drives.append(record)"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Correctly fetch file extension from item object", "commit": "1de2c55966c0ddf0fb663b935427d2c005f0fde9", "diff": "commit 1de2c55966c0ddf0fb663b935427d2c005f0fde9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 14:38:53 2025 +0100\n\n    Update drive.\n\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 1f159fb..1026cf7 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -27,7 +27,7 @@ class DriveView(BaseView):\n             record['items'] = []\n             async for item in drive.items:\n                 drive_item_record = item.record\n-                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + drive_item.extension\n+                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + item.extension\n                 record['items'].append(item.record)\n             drives.append(record)"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Improved terminal session handling and websocket integration", "commit": "529606955a545bc25cf5899f2c79d3660bcefd54", "diff": "commit 529606955a545bc25cf5899f2c79d3660bcefd54\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 18:46:10 2025 +0100\n\n    Repaired websockets.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex daefbd0..f200908 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -180,8 +180,8 @@ class Application(BaseApplication):\n \n executor = ThreadPoolExecutor(max_workers=100)\n \n-loop = asyncio.get_event_loop()\n-loop.set_default_executor(executor)\n \n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 3495bef..6a91040 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -13,80 +13,42 @@ commands = {\n }\n \n class TerminalSession:\n-    \n-    async def ensure_process(self):\n-        if self.process:\n-            return\n-        self.process = await asyncio.create_subprocess_exec(\n-            *self.command.split(\" \"),\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.history = b''\n+        self.history_size = 1024*20\n+        self.process = subprocess.Popen(\n+            command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n-            stderr=self.slave,bufsize=0,\n+            stderr=self.slave,\n+            bufsize=0,\n             universal_newlines=True\n         )\n \n-\n-    def __init__(self,command):\n-        self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.buffer = b''\n-        self.process = None \n-\n-\n+    async def add_websocket(self, ws):\n+        asyncio.create_task(self.read_output(ws))\n \n     async def read_output(self, ws):\n-        await self.ensure_process()\n         self.sockets.append(ws)\n-        if len(self.sockets) > 1:\n-            start = self.buffer.index(b'\\n')\n-            await ws.send_bytes(self.buffer[start:])\n-            return \n-        while True:\n+        if len(self.sockets) > 1 and self.buffer:\n+            start = 0\n             try:\n-                async for data in self.process.stdout:\n-                    if not data:\n-                        break\n-                    self.buffer += data\n-                    if len(self.buffer) > 10000:\n-                        self.buffer = self.buffer[:-10000]\n-                    try:\n-                    except:\n-                        self.sockets.remove(ws)\n-            except:\n-                print(\"Terminating process\")\n-                self.process.terminate()\n-                print(\"Terminated process\")\n-                for ws in self.sockets:\n-                    try:\n-                        await ws.close()\n-                    except Exception:\n-                        pass\n-                break\n-\n-    async def read_outputa(self, ws):\n-        loop = asyncio.get_event_loop()\n-        self.sockets.append(ws)\n-        if len(self.sockets) > 1:\n-            start = self.buffer.index(b'\\n')\n-            await ws.send_bytes(self.buffer[start:])\n+                start = self.history.index(b'\\n')\n+            except ValueError:\n+                pass \n+            await ws.send_bytes(self.history[start:])\n             return \n+        loop = asyncio.get_event_loop()\n         while True:\n             try:\n                 data = await loop.run_in_executor(None, os.read, self.master, 1024)\n                 if not data:\n                     break\n-                self.buffer += data\n-                if len(self.buffer) > 10000:\n-                    self.buffer = self.buffer[:-10000]\n+                self.history += data\n+                if len(self.history) > self.history_size:\n+                    self.history = self.history[:0-self.history_size]\n                 try:\n                 except:\n@@ -103,7 +65,10 @@ class TerminalSession:\n                 break\n \n     async def write_input(self, data):\n-        await self.ensure_process()\n-        os.write(self.master, data.encode())\n-\n-\n+        try:\n+            data = data.encode()\n+        except AttributeError:\n+            pass\n+        await asyncio.get_event_loop().run_in_executor(\n+            None, os.write, self.master, data\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 8af82e7..26de464 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -37,7 +37,8 @@ class TerminalSocketView(BaseView):\n         if not session:\n             self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n         session = self.user_sessions[user[\"uid\"]]   \n-        asyncio.create_task(session.read_output(ws))  \n+        await session.add_websocket(ws)\n \n         async for msg in ws:\n             if msg.type == aiohttp.WSMsgType.BINARY:"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Increased websocket thread pool size", "commit": "af4a70e8949bfde704a0499177050fdeab5300d9", "diff": "commit af4a70e8949bfde704a0499177050fdeab5300d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 18:52:12 2025 +0100\n\n    Repaired websockets.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f200908..ecc1999 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -178,10 +178,10 @@ class Application(BaseApplication):\n \n         return await super().render_template(template, request, context)\n \n-executor = ThreadPoolExecutor(max_workers=100)\n+executor = ThreadPoolExecutor(max_workers=200)\n \n+loop = asyncio.get_event_loop()\n+loop.set_default_executor(executor)\n "}
{"repo": ".", "date": "2025-03-27", "line": "feat: Display channel color and new message count in sidebar", "commit": "5390b8bdc3dc04645258ed758f3894de00008e80", "diff": "commit 5390b8bdc3dc04645258ed758f3894de00008e80\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:10:02 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ecc1999..1d225cc 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -160,6 +160,11 @@ class Application(BaseApplication):\n                 other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n                 parent_object = await subscribed_channel.get_channel()\n                 last_message =await parent_object.get_last_message()\n+                color = None \n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                item['color'] = color\n                 item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n                 item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n                 if other_user:\n@@ -168,6 +173,9 @@ class Application(BaseApplication):\n                 else:\n                     item[\"name\"] = subscribed_channel[\"label\"]\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                item['new_count'] = subscribed_channel['new_count'] \n+                \n+                print(item)\n                 channels.append(item)\n             \n             channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 0070948..649299e 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -13,9 +13,10 @@ class ChannelModel(BaseModel):\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n     async def get_last_message(self)->ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n-            return model \n+        async for model in self.app.services.channel_message.query(\"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n+            \n+            return await self.app.services.channel_message.get(uid=model['uid'])\n         return None\n \n     async def get_members(self):\n-        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n\\ No newline at end of file\n+        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f8a87c8..a5d4ebd 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -51,6 +51,7 @@ class NotificationService(BaseService):\n             model[\"message\"] = (\n                 f\"New message from {user['nick']} in {channel_member['label']}.\"\n             )\n-            if await self.save(model):\n-                return model\n-            raise Exception(f\"Failed to create notification: {model.errors}.\")\n+            try:\n+                await self.save(model)\n+            except Exception as ex:\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 2d034b7..66371f1 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -12,13 +12,13 @@\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n       {% for channel in channels if not channel['is_private'] %}\n-      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" {% if channel['color'] %}style=\"color: {{channel['color']}}\"{% endif %}  href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\">{% if channel['new_count'] %}({{ channel['new_count'] }}){% endif %}</span></a></li>\n       {% endfor %}\n     </ul>\n     <h2 class=\"no-select\">Private</h2>\n     <ul>\n       {% for channel in channels if channel['is_private'] %}\n-      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a  {% if channel['color'] %}style=\"color: {{channel['color']}}\"{% endif %}  class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\">{% if channel['new_count'] %}({{ channel['new_count'] }}){% endif %}</span></a></li>\n       {% endfor %}\n     </ul>\n     {% endif %}\n@@ -28,12 +28,21 @@\n         constructor(el){\n           this.el = el \n         }\n+        async init(){\n+            \n+            const channels = await window.app.rpc.getChannels()\n+            channels.forEach(channel => {\n+                if(channel.color){\n+                this.setMessageCount(channel.uid, channel.new_count)\n+                this.setColor(channel.uid, channel.color)\n+                }\n+            })\n+        }\n         get channelNodes() {\n           return this.el.querySelectorAll(\"li\")\n         }\n         channelListItemByUid(channelUid){\n           const id = \"channel-list-item-\" + channelUid;\n-          console.error(id)\n           return document.getElementById(id)\n         }\n         incrementMessageCount(channelUid){\n@@ -52,11 +61,22 @@\n           }\n         }\n         setMessageCount(channelUid, count){\n-          const li = this.channelListItemByUid(channelUid);\n+            \n+            const li = this.channelListItemByUid(channelUid);\n           if(li){\n             li.dataset.messageCount = new String(count)\n             li.dataset['messageCount'] = count\n-            li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+            if(!count){\n+                li.querySelector(\".message-count\").textContent = ''\n+            }else{\n+              li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+            }\n+          }\n+        }\n+        setColor(channelUid, color){\n+          const li = this.channelListItemByUid(channelUid);\n+            if(li){\n+                li.querySelector(\"a\").style.color = color\n           }\n         }\n         notify(message){\n@@ -73,4 +93,7 @@\n     }\n     const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n \n+    document.addEventListener(\"DOMContentLoaded\", () => {\n+    channelSidebar.init()\n+    })\n   </script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 55accc7..b5c3504 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -90,13 +90,20 @@ class RPCView(BaseView):\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n                 channel = await self.services.channel.get(uid=subscription['channel_uid'])\n+                last_message = await channel.get_last_message()\n+                color = None \n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user['color']\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n                     \"tag\": channel[\"tag\"],\n                     \"new_count\": subscription[\"new_count\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n-                    \"is_read_only\": subscription[\"is_read_only\"]\n+                    \"is_read_only\": subscription[\"is_read_only\"],\n+                    'new_count': subscription['new_count'],\n+                    'color': color \n                 })\n             return channels\n \n@@ -156,9 +163,11 @@ class RPCView(BaseView):\n                 if result != \"noresponse\":\n                     await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n+                print(str(ex), flush=True)\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n         async def _send_json(self, obj):\n+            print(obj, flush=True)\n             await self.ws.send_str(json.dumps(obj, default=str))\n     \n \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex cdde6e3..b9271c3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,6 +46,9 @@ class WebView(BaseView):\n         channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n         if not channel_member:\n             return web.HTTPNotFound()\n+        \n+        channel_member['new_count'] = 0\n+        await self.app.services.channel_member.save(channel_member)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n@@ -54,23 +57,5 @@ class WebView(BaseView):\n         for message in messages:\n             await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n \n-        channels = []\n-        async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-            item = {}\n-            other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n-            parent_object = await subscribed_channel.get_channel()\n-            last_message =await parent_object.get_last_message()\n-            item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n-            item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n-            if other_user:\n-                item[\"name\"] = other_user[\"nick\"]\n-                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-            else:\n-                item[\"name\"] = subscribed_channel[\"label\"]\n-                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-            channels.append(item)\n-        \n-        channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n-\n         name = await channel_member.get_name()\n-        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n+        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages})"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Prevent errors when user data is missing during notification updates", "commit": "877ef7970d5b7bb5f8fd0af35adad5d8d071b14d", "diff": "commit 877ef7970d5b7bb5f8fd0af35adad5d8d071b14d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:15:39 2025 +0100\n\n    Bugfix.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a5d4ebd..e6915bf 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -41,6 +41,8 @@ class NotificationService(BaseService):\n             channel_member['new_count'] += 1\n             \n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if not usr:\n+                continue\n             print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Remove debugging prints", "commit": "87c189b3fe897cc15ee6ac311e987bf9fe7811b9", "diff": "commit 87c189b3fe897cc15ee6ac311e987bf9fe7811b9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:28:54 2025 +0100\n\n    Removed useless prints.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 42415d1..5c6c7ee 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -5,6 +5,13 @@ class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n+    async def mark_as_read(self, channel_uid, user_uid):\n+        channel_member =  await self.get(\n+            channel_uid=channel_uid,\n+            user_uid=user_uid\n+        )\n+        channel_member[\"new_count\"] = 0\n+        return await self.save(channel_member)\n \n     async def create(\n         self,\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex e6915bf..0b54a23 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -43,7 +43,6 @@ class NotificationService(BaseService):\n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if not usr:\n                 continue\n-            print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex e7415e1..3b6c7c6 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -49,7 +49,6 @@ class BaseMapper:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n         model.updated_at.update()\n-        print(model.record,flush=True)\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 5371d33..3d52892 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -70,4 +70,3 @@ class BaseFormView(BaseView):\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\n-        print(\"Submit sucess\")\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8a68f64..28ad4d2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -184,6 +184,7 @@\n         setTimeout(() => {\n             updateLayout(doScrollDownBecauseLastMessageIsVisible)\n         }, 1000);\n+        app.rpc.markAsRead(channelUid);\n     });\n \n     initInputField(getInputField());\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b5c3504..27a8dcf 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -39,9 +39,9 @@ class RPCView(BaseView):\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n-        async def mark_as_read(self, message_uid):\n+        async def mark_as_read(self, channel_uid):\n             self._require_login()\n-            await self.services.notification.mark_as_read(self.user_uid, message_uid)\n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid) \n             return True\n \n         async def login(self, username, password):\n@@ -167,7 +167,6 @@ class RPCView(BaseView):\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n         async def _send_json(self, obj):\n-            print(obj, flush=True)\n             await self.ws.send_str(json.dumps(obj, default=str))\n     \n \ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 26de464..fac680c 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -31,7 +31,6 @@ class TerminalSocketView(BaseView):\n         root = await self.prepare_drive()\n         \n         command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n-        print(command)\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Removed unnecessary print statements", "commit": "71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71", "diff": "commit 71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:30:06 2025 +0100\n\n    Removed useless prints.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 3d52892..2765642 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -70,3 +70,4 @@ class BaseFormView(BaseView):\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\n+        pass"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction for notification creation", "commit": "bd5bb5ae65d6cb870d090382b106b705833d4cf1", "diff": "commit bd5bb5ae65d6cb870d090382b106b705833d4cf1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:10:05 2025 +0100\n\n    Transaction.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 0b54a23..1392044 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -30,6 +30,7 @@ class NotificationService(BaseService):\n             uid=channel_message_uid\n         )\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        self.app.db.begin()\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n             is_banned=False,\n@@ -56,3 +57,5 @@ class NotificationService(BaseService):\n                 await self.save(model)\n             except Exception as ex:\n                 raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+        self.app.db.commit()"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Persist RPC transaction changes to database", "commit": "13ce09a5c50ddba8956dbeb838f9dd1bcafe184a", "diff": "commit 13ce09a5c50ddba8956dbeb838f9dd1bcafe184a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:20:48 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 27a8dcf..5720f04 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -203,7 +203,9 @@ class RPCView(BaseView):\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     async with Profiler():\n+                        self.app.db.begin()\n                         await rpc(msg.json())\n+                        self.app.db.commit()\n                 except Exception as ex:\n                     print(ex, flush=True)\n                     await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task runner and task queue for asynchronous operations", "commit": "145373399dada2f4cee54af0c99e62d4a27a0f99", "diff": "commit 145373399dada2f4cee54af0c99e62d4a27a0f99\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:41:04 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 1d225cc..3094dd5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -61,6 +61,7 @@ class Application(BaseApplication):\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n+        self.tasks = []\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n@@ -68,12 +69,28 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n-       \n+     \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n         self.on_startup.append(self.prepare_database)\n \n+    async def create_task(self, task):\n+        self.tasks.append(task)\n+\n+    async def task_runner(self):\n+        while True:\n+            await asyncio.sleep(0.1)\n+            task = None\n+            try:\n+                task = self.tasks.pop(0)\n+            except IndexError:\n+                continue \n+            try:\n+                await task\n+            except:\n+                print(ex)\n+\n     async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n@@ -89,6 +106,7 @@ class Application(BaseApplication):\n             pass \n  \n         await app.services.drive.prepare_all()\n+        self.loop.create_task(self.task_runner())\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 17c677b..ecaedb7 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -21,7 +21,10 @@ class ChatService(BaseService):\n         \n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n-        sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n+        channel['last_message_on'] = now()\n+        await self.services.channel.save(channel)\n+        \n+        await self.app.create_task(self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -32,7 +35,5 @@ class ChatService(BaseService):\n             username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n-        ))\n-        channel['last_message_on'] = now()\n-        await self.services.channel.save(channel)\n-        return sent_to_count\n\\ No newline at end of file\n+        )))\n+        return True"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Improve task management with asyncio.Queue and error handling", "commit": "e6f702a6b405f1dae0ce719177c6f7bf5b636ad8", "diff": "commit e6f702a6b405f1dae0ce719177c6f7bf5b636ad8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:56:35 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3094dd5..7c0f0b0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -61,7 +61,7 @@ class Application(BaseApplication):\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n-        self.tasks = []\n+        self.tasks = asyncio.Queue()\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n@@ -76,19 +76,14 @@ class Application(BaseApplication):\n         self.on_startup.append(self.prepare_database)\n \n     async def create_task(self, task):\n-        self.tasks.append(task)\n+        await self.tasks.put(task)\n \n     async def task_runner(self):\n         while True:\n-            await asyncio.sleep(0.1)\n-            task = None\n-            try:\n-                task = self.tasks.pop(0)\n-            except IndexError:\n-                continue \n+            task = await self.tasks.get() \n             try:\n                 await task\n-            except:\n+            except Exception as ex:\n                 print(ex)\n \n     async def prepare_database(self,app):\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 0b6071d..d941321 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -62,12 +62,12 @@ class SocketService(BaseService):\n     async def broadcast(self, channel_uid, message):\n         count = 0\n         async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n-            count += await self.send_to_user(channel_member[\"user_uid\"],message)\n-        return count\n+            await self.send_to_user(channel_member[\"user_uid\"],message)\n+        return True \n     \n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n             self.sockets.remove(s)\n             \n-    \n\\ No newline at end of file\n+"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Remove unnecessary database transaction block in RPCView", "commit": "9d5815ed1028354ac61f2d506530147a02c476e6", "diff": "commit 9d5815ed1028354ac61f2d506530147a02c476e6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:00:34 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5720f04..27a8dcf 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -203,9 +203,7 @@ class RPCView(BaseView):\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     async with Profiler():\n-                        self.app.db.begin()\n                         await rpc(msg.json())\n-                        self.app.db.commit()\n                 except Exception as ex:\n                     print(ex, flush=True)\n                     await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction management for task execution", "commit": "8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2", "diff": "commit 8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:01:17 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7c0f0b0..67beb5d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -81,10 +81,12 @@ class Application(BaseApplication):\n     async def task_runner(self):\n         while True:\n             task = await self.tasks.get() \n+            self.db.begin()\n             try:\n                 await task\n             except Exception as ex:\n                 print(ex)\n+            self.db.commit()\n \n     async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task execution time measurement", "commit": "6ad3844f037735e268b42247fcc6e8605cc13f07", "diff": "commit 6ad3844f037735e268b42247fcc6e8605cc13f07\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:07:04 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 67beb5d..dd3e0f3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,7 @@ import pathlib\n import asyncio\n \n import logging\n+import time\n \n from snek.view.threads import ThreadsView \n \n@@ -83,7 +84,11 @@ class Application(BaseApplication):\n             task = await self.tasks.get() \n             self.db.begin()\n             try:\n+                task_start = time.time()\n                 await task\n+                task_end = time.time()\n+                print(f\"Task {task} took {task_end - task_start} seconds\")\n+                self.tasks.task_done()\n             except Exception as ex:\n                 print(ex)\n             self.db.commit()"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Asynchronously create channel messages and improve socket broadcast", "commit": "73e8779bdc42ec5f5618fad3d563544d1fad2b69", "diff": "commit 73e8779bdc42ec5f5618fad3d563544d1fad2b69\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:11:02 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex ecaedb7..a008c70 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -20,11 +20,11 @@ class ChatService(BaseService):\n         \n         \n         user = await self.services.user.get(uid=user_uid)\n-        await self.services.notification.create_channel_message(channel_message_uid)\n+        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n         channel['last_message_on'] = now()\n         await self.services.channel.save(channel)\n         \n-        await self.app.create_task(self.services.socket.broadcast(channel_uid, dict(\n+        self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -35,5 +35,5 @@ class ChatService(BaseService):\n             username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n-        )))\n+        ))\n         return True"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Ensure notification creation after socket broadcast", "commit": "6f043d21390d13d37772c8ae145d7a66b3919529", "diff": "commit 6f043d21390d13d37772c8ae145d7a66b3919529\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:12:19 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex a008c70..aef0cdd 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -20,11 +20,10 @@ class ChatService(BaseService):\n         \n         \n         user = await self.services.user.get(uid=user_uid)\n-        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n         channel['last_message_on'] = now()\n         await self.services.channel.save(channel)\n-        \n-        self.services.socket.broadcast(channel_uid, dict(\n+         \n+        await self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -36,4 +35,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n+        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n+       \n         return True"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Await task creation in chat service", "commit": "9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec", "diff": "commit 9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:14:01 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex aef0cdd..8b1f8ad 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -35,6 +35,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n-        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n+        await self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n        \n         return True"}
{"repo": ".", "date": "2025-03-28", "line": "feat: Initialized Ubuntu terminal environment with Dockerfile and basic setup", "commit": "5fbcadad8bad7b8dd7ac3279938ff50db6b5d380", "diff": "commit 5fbcadad8bad7b8dd7ac3279938ff50db6b5d380\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 28 02:41:57 2025 +0100\n\n    Terminal Update.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nnew file mode 100644\nindex 0000000..45c6038\n--- /dev/null\n+++ b/DockerfileUbuntu\n@@ -0,0 +1,11 @@\n+FROM ubuntu:latest\n+\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget -y\n+\n+\n+RUN chmod +x r\n+\n+RUN cp r /usr/local/bin\n+\n+CMD [\"r\"]\ndiff --git a/Makefile b/Makefile\nindex c9a7a28..878e699 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -11,12 +11,15 @@ python:\n dump:\n \t@$(PYTHON) -m snek.dump\n \n+build:\n+\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\n install:\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n-\n+\tdocker build -f DockerfileUbuntu -t snek_ubuntu .\n \n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 6a91040..80dda17 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -32,7 +32,7 @@ class TerminalSession:\n \n     async def read_output(self, ws):\n         self.sockets.append(ws)\n-        if len(self.sockets) > 1 and self.buffer:\n+        if len(self.sockets) > 1 and self.history:\n             start = 0\n             try:\n                 start = self.history.index(b'\\n')\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex fac680c..6596f2c 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -30,7 +30,10 @@ class TerminalSocketView(BaseView):\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = await self.prepare_drive()\n         \n-        command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n+\n+        \n+\n+        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex e4022a9..ee7d93f 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -92,11 +92,6 @@ if [ -f ~/.bash_aliases ]; then\n fi\n \n \n-cp ~/r /usr/local/bin \n-\n-chmod +x /usr/local/bin/r\n-\n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git -y\n \n echo \"R is installed. Type r to run it.\"\n \ndiff --git a/terminal/r b/terminal/r\ndeleted file mode 100755\nindex 2cc65df..0000000\nBinary files a/terminal/r and /dev/null differ"}
{"repo": ".", "date": "2025-03-29", "line": "fix: Adjusted avatar size in message template", "commit": "fe1b3d6d191176111538f1ba06018e14c44ca8f9", "diff": "commit fe1b3d6d191176111538f1ba06018e14c44ca8f9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 01:21:33 2025 +0100\n\n    Fixed avatar issue\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0b475d1..31a6e74 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -254,6 +254,7 @@ input[type=\"text\"], .chat-input textarea {\n   }\n \n   .avatar {\n+    \n     opacity: 0;\n   }\n \ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex e8afc31..9773ae1 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Added webdav support for file management", "commit": "29139d5d0c18ad2b0ebd32db9a0b629c45c0a651", "diff": "commit 29139d5d0c18ad2b0ebd32db9a0b629c45c0a651\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:13:23 2025 +0100\n\n    Added webdav.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex e1075c9..6fbf200 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -15,6 +15,8 @@ keywords = [\"chat\", \"snek\", \"molodetz\"]\n requires-python = \">=3.12\"\n dependencies = [\n     \"mkdocs>=1.4.0\",\n+    \"lxml\",\n+\n     \"shed\",\n     \"beautifulsoup4\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex dd3e0f3..984fcf3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,13 +1,14 @@\n-import pathlib\n import asyncio\n-\n import logging\n+import pathlib\n import time\n \n-from snek.view.threads import ThreadsView \n+from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n \n+from concurrent.futures import ThreadPoolExecutor\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,\n@@ -24,23 +25,24 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n-from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension\n+from snek.system.profiler import profiler_handler\n+from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.view.avatar import AvatarView\n from snek.view.docs import DocsHTMLView, DocsMDView\n+from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n+from snek.view.search_user import SearchUserView\n from snek.view.status import StatusView\n-from snek.view.web import WebView\n+from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n-from snek.view.search_user import SearchUserView \n-from snek.view.avatar import AvatarView\n-from snek.system.profiler import profiler_handler\n-from snek.view.terminal import TerminalView, TerminalSocketView\n-from snek.view.drive import DriveView\n-from  concurrent.futures import ThreadPoolExecutor\n+from snek.view.web import WebView\n+from snek.webdav import WebdavApplication\n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -50,6 +52,15 @@ async def session_middleware(request, handler):\n     response = await handler(request)\n     return response\n \n+\n+@web.middleware\n+async def trailing_slash_middleware(request, handler):\n+    if request.path and not request.path.endswith(\"/\"):\n+        raise web.HTTPFound(request.path + \"/\")\n+    return await handler(request)\n+\n+\n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n@@ -68,9 +79,9 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n-        \n+\n         self.setup_router()\n-     \n+\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n@@ -81,7 +92,7 @@ class Application(BaseApplication):\n \n     async def task_runner(self):\n         while True:\n-            task = await self.tasks.get() \n+            task = await self.tasks.get()\n             self.db.begin()\n             try:\n                 task_start = time.time()\n@@ -93,20 +104,20 @@ class Application(BaseApplication):\n                 print(ex)\n             self.db.commit()\n \n-    async def prepare_database(self,app):\n+    async def prepare_database(self, app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n \n         try:\n             if not self.db[\"user\"].has_index(\"username\"):\n                 self.db[\"user\"].create_index(\"username\", unique=True)\n-            if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n-                self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n-            if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n-                self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n         except:\n-            pass \n- \n+            pass\n+\n         await app.services.drive.prepare_all()\n         self.loop.create_task(self.task_runner())\n \n@@ -145,6 +156,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/terminal.html\", TerminalView)\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.webdav = WebdavApplication(self)\n+        self.add_subapp(\"/webdav\", self.webdav)\n \n         self.add_subapp(\n             \"/docs\",\n@@ -174,17 +187,21 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-        if request.session.get(\"uid\"): \n-            async for subscribed_channel in self.services.channel_member.find(user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+        if request.session.get(\"uid\"):\n+            async for subscribed_channel in self.services.channel_member.find(\n+                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n                 item = {}\n-                other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n+                other_user = await self.services.channel_member.get_other_dm_user(\n+                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n+                )\n                 parent_object = await subscribed_channel.get_channel()\n-                last_message =await parent_object.get_last_message()\n-                color = None \n+                last_message = await parent_object.get_last_message()\n+                color = None\n                 if last_message:\n                     last_message_user = await last_message.get_user()\n                     color = last_message_user[\"color\"]\n-                item['color'] = color\n+                item[\"color\"] = color\n                 item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n                 item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n                 if other_user:\n@@ -193,19 +210,22 @@ class Application(BaseApplication):\n                 else:\n                     item[\"name\"] = subscribed_channel[\"label\"]\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                item['new_count'] = subscribed_channel['new_count'] \n-                \n+                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n+\n                 print(item)\n                 channels.append(item)\n-            \n-            channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n-            if not 'channels' in context:\n-                context['channels'] = channels\n-            if not 'user' in context:\n-                context['user'] = await self.services.user.get(uid=request.session.get(\"uid\"))\n+\n+            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+            if \"channels\" not in context:\n+                context[\"channels\"] = channels\n+            if \"user\" not in context:\n+                context[\"user\"] = await self.services.user.get(\n+                    uid=request.session.get(\"uid\")\n+                )\n \n         return await super().render_template(template, request, context)\n \n+\n executor = ThreadPoolExecutor(max_workers=200)\n \n loop = asyncio.get_event_loop()\n@@ -213,8 +233,10 @@ loop.set_default_executor(executor)\n \n \n+\n async def main():\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n+\n if __name__ == \"__main__\":\n     asyncio.run(main())\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 1b7eb6b..b254756 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,31 +1,39 @@\n import asyncio\n-import json \n \n from snek.app import app\n \n+\n async def fix_message(message):\n-    message = dict(\n-        uid=message['uid'],\n-        user_uid=message['user_uid'],\n-        text=message['message'],\n-        sent=message['created_at']\n-    )\n-    user = await app.services.user.get(uid=message['user_uid'])\n-    message['user'] = user and user['username'] or None\n-    return (message['user'] or '') + ': ' + (message['text'] or '')\n+    message = {\n+        \"uid\": message[\"uid\"],\n+        \"user_uid\": message[\"user_uid\"],\n+        \"text\": message[\"message\"],\n+        \"sent\": message[\"created_at\"],\n+    }\n+    user = await app.services.user.get(uid=message[\"user_uid\"])\n+    message[\"user\"] = user and user[\"username\"] or None\n+    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n+\n \n async def dump_public_channels():\n     result = []\n-    for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n+    for channel in app.db[\"channel\"].find(\n+        is_private=False, is_listed=True, tag=\"public\"\n+    ):\n         print(f\"Dumping channel: {channel['label']}.\")\n-        result += [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        result += [\n+            await fix_message(record)\n+            for record in app.db[\"channel_message\"].find(\n+                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n+            )\n+        ]\n         print(\"Dump succesfull!\")\n     print(\"Converting to json.\")\n     print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.txt\",\"w\") as f:\n-        f.write('\\n\\n'.join(result))\n+    with open(\"dump.txt\", \"w\") as f:\n+        f.write(\"\\n\\n\".join(result))\n     print(\"Dump written to dump.json\")\n-    \n \n-if __name__ == '__main__':\n+\n+if __name__ == \"__main__\":\n     asyncio.run(dump_public_channels())\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex 6f431f0..7e946b9 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -16,4 +16,3 @@ class SearchUserForm(Form):\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n     )\n-\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex e4c67b0..96053ea 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -3,10 +3,10 @@ import functools\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n+from snek.mapper.drive import DriveMapper\n+from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n-from snek.mapper.drive import DriveMapper \n-from snek.mapper.drive_item import DriveItemMapper\n from snek.system.object import Object\n \n \ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex 970788a..c92c687 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -3,5 +3,5 @@ from snek.system.mapper import BaseMapper\n \n \n class DriveMapper(BaseMapper):\n-    table_name = 'drive'\n-    model_class = DriveModel \n+    table_name = \"drive\"\n+    model_class = DriveModel\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex c35afe1..3d17a61 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,7 +1,8 @@\n+from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-from snek.model.drive_item import DriveItemModel \n+\n \n class DriveItemMapper(BaseMapper):\n-    \n+\n     model_class = DriveItemModel\n-    table_name = 'drive_item'\n+    table_name = \"drive_item\"\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 649299e..183ddb0 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -12,11 +12,16 @@ class ChannelModel(BaseModel):\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n-    async def get_last_message(self)->ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n-            \n-            return await self.app.services.channel_message.get(uid=model['uid'])\n+    async def get_last_message(self) -> ChannelMessageModel:\n+        async for model in self.app.services.channel_message.query(\n+            \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+            {\"channel_uid\": self[\"uid\"]},\n+        ):\n+\n+            return await self.app.services.channel_message.get(uid=model[\"uid\"])\n         return None\n \n     async def get_members(self):\n-        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n+        return await self.app.services.channel_member.find(\n+            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n+        )\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 9689fa5..54b0418 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -16,25 +16,26 @@ class ChannelMemberModel(BaseModel):\n     new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n \n     async def get_user(self):\n-        return await self.app.services.user.get(uid=self['user_uid'])\n-    \n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n     async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self['channel_uid'])\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n \n     async def get_name(self):\n         channel = await self.get_channel()\n         if channel[\"tag\"] == \"dm\":\n             user = await self.get_other_dm_user()\n-            return user['nick']\n-        return channel['name'] or self['label']\n+            return user[\"nick\"]\n+        return channel[\"name\"] or self[\"label\"]\n \n     async def get_other_dm_user(self):\n         channel = await self.get_channel()\n         if channel[\"tag\"] != \"dm\":\n             return None\n-        \n-        async for model in self.app.services.channel_member.find(channel_uid=channel['uid']):\n-            if model[\"uid\"] != self['uid']:\n+\n+        async for model in self.app.services.channel_member.find(\n+            channel_uid=channel[\"uid\"]\n+        ):\n+            if model[\"uid\"] != self[\"uid\"]:\n                 return await self.app.services.user.get(uid=model[\"user_uid\"])\n         return await self.get_user()\n-        \ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 4b84fdc..524a8a4 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -8,8 +8,8 @@ class ChannelMessageModel(BaseModel):\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n \n-    async def get_user(self)->UserModel:\n+    async def get_user(self) -> UserModel:\n         return await self.app.services.user.get(uid=self[\"user_uid\"])\n-    \n+\n     async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n\\ No newline at end of file\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex 3936d97..df17d0f 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,13 +1,14 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n \n \n class DriveModel(BaseModel):\n \n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    name = ModelField(name='name', required=False, type=str) \n+    name = ModelField(name=\"name\", required=False, type=str)\n \n     @property\n     async def items(self):\n-        async for drive_item in self.app.services.drive_item.find(drive_uid=self['uid']):\n+        async for drive_item in self.app.services.drive_item.find(\n+            drive_uid=self[\"uid\"]\n+        ):\n             yield drive_item\n-\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 728cd89..6e28f84 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,18 +1,20 @@\n-from snek.system.model import BaseModel,ModelField \n import mimetypes\n \n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveItemModel(BaseModel):\n-    drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n-    name = ModelField(name=\"name\", required=True,kind=str)\n-    path = ModelField(name=\"path\", required=True,kind=str)\n-    file_type = ModelField(name=\"file_type\", required=True,kind=str) \n-    file_size = ModelField(name=\"file_size\", required=True,kind=int)\n-    \n+    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n+    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+\n     @property\n     def extension(self):\n-        return self['name'].split('.')[-1]\n-    \n-    @property \n+        return self[\"name\"].split(\".\")[-1]\n+\n+    @property\n     def mime_type(self):\n-        mimetype,_ = mimetypes.guess_type(self['name'])\n-        return mimetype \n+        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n+        return mimetype\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 0621ecf..a35d890 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -18,10 +18,7 @@ class UserModel(BaseModel):\n         regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     color = ModelField(\n-        name =\"color\",\n-        required=True,\n-        kind=str\n     )\n     email = ModelField(\n         name=\"email\",\n@@ -33,5 +30,7 @@ class UserModel(BaseModel):\n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n     async def get_channel_members(self):\n-        async for channel_member in self.app.services.channel_member.find(user_uid=self['uid'],is_banned=False,deleted_at=None):\n+        async for channel_member in self.app.services.channel_member.find(\n+            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n+        ):\n             yield channel_member\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex e521c7b..4059f77 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -4,12 +4,12 @@ from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n from snek.service.chat import ChatService\n+from snek.service.drive import DriveService\n+from snek.service.drive_item import DriveItemService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n-from snek.service.drive import DriveService\n-from snek.service.drive_item import DriveItemService\n from snek.system.object import Object\n \n \n@@ -26,7 +26,7 @@ def get_services(app):\n             \"notification\": NotificationService(app=app),\n             \"util\": UtilService(app=app),\n             \"drive\": DriveService(app=app),\n-            \"drive_item\": DriveItemService(app=app)\n+            \"drive_item\": DriveItemService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 95ceef7..b90e66f 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,30 +1,28 @@\n+from datetime import datetime\n+\n+from snek.system.model import now\n from snek.system.service import BaseService\n-from datetime import datetime \n \n-from snek.system.model import now \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n-    async def get(\n-        self,\n-        uid=None,\n-        **kwargs):\n+    async def get(self, uid=None, **kwargs):\n         if uid:\n-            kwargs['uid'] = uid \n+            kwargs[\"uid\"] = uid\n             result = await super().get(**kwargs)\n             if result:\n-                return result \n-            del kwargs['uid']\n-            kwargs['name'] = uid \n+                return result\n+            del kwargs[\"uid\"]\n+            kwargs[\"name\"] = uid\n             result = await super().get(**kwargs)\n             if result:\n                 return result\n             result = await super().get(**kwargs)\n             if result:\n                 return result\n-            return None \n+            return None\n         return await super().get(**kwargs)\n \n     async def create(\n@@ -53,38 +51,34 @@ class ChannelService(BaseService):\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n     async def get_dm(self, user1, user2):\n-        channel_member = await self.services.channel_member.get_dm(\n-            user1, user2 \n-        )\n+        channel_member = await self.services.channel_member.get_dm(user1, user2)\n         if channel_member:\n             return await self.get(uid=channel_member[\"channel_uid\"])\n-        channel = await self.create(\n-            \"DM\", user1, tag=\"dm\"            \n-        )\n-        await self.services.channel_member.create_dm(\n-            channel[\"uid\"], user1, user2            \n-        )\n-        return channel \n+        channel = await self.create(\"DM\", user1, tag=\"dm\")\n+        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n+        return channel\n \n     async def get_users(self, channel_uid):\n-        users = []\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_uid,\n             is_banned=False,\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            user =  await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if user:\n                 yield user\n+\n     async def get_online_users(self, channel_uid):\n-        users = []\n         async for user in self.get_users(channel_uid):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n-                yield user \n+            if (\n+                datetime.fromisoformat(now())\n+                - datetime.fromisoformat(user[\"last_ping\"])\n+            ).total_seconds() < 20:\n+                yield user\n \n     async def get_for_user(self, user_uid):\n         async for channel_member in self.services.channel_member.find(\n@@ -93,7 +87,7 @@ class ChannelService(BaseService):\n             deleted_at=None,\n         ):\n             channel = await self.get(uid=channel_member[\"channel_uid\"])\n-            yield channel \n+            yield channel\n \n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 5c6c7ee..16d1887 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -6,10 +6,7 @@ class ChannelMemberService(BaseService):\n     mapper_name = \"channel_member\"\n \n     async def mark_as_read(self, channel_uid, user_uid):\n-        channel_member =  await self.get(\n-            channel_uid=channel_uid,\n-            user_uid=user_uid\n-        )\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         channel_member[\"new_count\"] = 0\n         return await self.save(channel_member)\n \n@@ -24,7 +21,7 @@ class ChannelMemberService(BaseService):\n     ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n-            if model['is_banned']:\n+            if model[\"is_banned\"]:\n                 return False\n             return model\n         model = await self.new()\n@@ -39,30 +36,32 @@ class ChannelMemberService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n-    \n-    async def get_dm(self,from_user, to_user):\n-        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+\n+    async def get_dm(self, from_user, to_user):\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n             return model\n         if not from_user == to_user:\n-            return None \n-        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n-           \n-            return model \n-    \n+            return None\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+\n+            return model\n+\n     async def get_other_dm_user(self, channel_uid, user_uid):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n+        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n         if channel[\"tag\"] != \"dm\":\n             return None\n         async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            if model[\"uid\"] != channel_member['uid']:\n+            if model[\"uid\"] != channel_member[\"uid\"]:\n                 return await self.services.user.get(uid=model[\"user_uid\"])\n \n-    async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n+    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n         result = await self.create(channel_uid, from_user_uid)\n         await self.create(channel_uid, to_user_uid)\n-        return result \n-\n-\n-\n-\n+        return result\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 9683631..f8a000f 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,71 +1,93 @@\n from snek.system.service import BaseService\n-import jinja2 \n+\n \n class ChannelMessageService(BaseService):\n     mapper_name = \"channel_message\"\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n-        \n+\n         model[\"channel_uid\"] = channel_uid\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n-                \n-        context = {\n-            \n-        }\n-        \n-        \n-        record = model.record \n+\n+        context = {}\n+\n+        record = model.record\n         context.update(record)\n         user = await self.app.services.user.get(uid=user_uid)\n-        context.update(dict(\n-            user_uid=user['uid'],\n-            username=user['username'],\n-            user_nick=user['nick'],\n-            color=user['color']\n-        ))\n+        context.update(\n+            {\n+                \"user_uid\": user[\"uid\"],\n+                \"username\": user[\"username\"],\n+                \"user_nick\": user[\"nick\"],\n+                \"color\": user[\"color\"],\n+            }\n+        )\n         try:\n             template = self.app.jinja2_env.get_template(\"message.html\")\n             model[\"html\"] = template.render(**context)\n         except Exception as ex:\n-            print(ex,flush=True)\n-        \n+            print(ex, flush=True)\n+\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n-    \n+\n     async def to_extended_dict(self, message):\n         user = await self.services.user.get(uid=message[\"user_uid\"])\n         if not user:\n             return {}\n         return {\n             \"uid\": message[\"uid\"],\n-            \"color\": user['color'],\n+            \"color\": user[\"color\"],\n             \"user_uid\": message[\"user_uid\"],\n             \"channel_uid\": message[\"channel_uid\"],\n-            \"user_nick\": user['nick'],\n+            \"user_nick\": user[\"nick\"],\n             \"message\": message[\"message\"],\n             \"created_at\": message[\"created_at\"],\n-            \"html\": message['html'],\n-            \"username\": user['username'] \n+            \"html\": message[\"html\"],\n+            \"username\": user[\"username\"],\n         }\n \n-    async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):\n+    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n         results = []\n-        offset = page * page_size \n+        offset = page * page_size\n         try:\n             if timestamp:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=timestamp)):\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n                     results.append(model)\n             elif page > 0:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n                     results.append(model)\n-            else: \n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+            else:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                    },\n+                ):\n                     results.append(model)\n \n-        except: \n+        except:\n             pass\n-        results.sort(key=lambda x: x['created_at'])\n-        return results \n+        results.sort(key=lambda x: x[\"created_at\"])\n+        return results\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 8b1f8ad..388d5c0 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,40 +1,39 @@\n-\n-\n-\n from snek.system.model import now\n from snek.system.service import BaseService\n \n \n class ChatService(BaseService):\n \n-    async def send(self,user_uid, channel_uid, message):\n+    async def send(self, user_uid, channel_uid, message):\n         channel = await self.services.channel.get(uid=channel_uid)\n         if not channel:\n             raise Exception(\"Channel not found.\")\n         channel_message = await self.services.channel_message.create(\n-            channel_uid, \n-            user_uid, \n-            message\n+            channel_uid, user_uid, message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        \n-        \n+\n         user = await self.services.user.get(uid=user_uid)\n-        channel['last_message_on'] = now()\n+        channel[\"last_message_on\"] = now()\n         await self.services.channel.save(channel)\n-         \n-        await self.services.socket.broadcast(channel_uid, dict(\n-            message=channel_message[\"message\"],\n-            html=channel_message[\"html\"],\n-            user_uid=user_uid,\n-            color=user['color'],\n-            channel_uid=channel_uid,\n-            created_at=channel_message[\"created_at\"], \n-            updated_at=None,\n-            username=user['username'],\n-            uid=channel_message['uid'],\n-            user_nick=user['nick']\n-        ))\n-        await self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n-       \n+\n+        await self.services.socket.broadcast(\n+            channel_uid,\n+            {\n+                \"message\": channel_message[\"message\"],\n+                \"html\": channel_message[\"html\"],\n+                \"user_uid\": user_uid,\n+                \"color\": user[\"color\"],\n+                \"channel_uid\": channel_uid,\n+                \"created_at\": channel_message[\"created_at\"],\n+                \"updated_at\": None,\n+                \"username\": user[\"username\"],\n+                \"uid\": channel_message[\"uid\"],\n+                \"user_nick\": user[\"nick\"],\n+            },\n+        )\n+        await self.app.create_task(\n+            self.services.notification.create_channel_message(channel_message_uid)\n+        )\n+\n         return True\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex b90a959..38035c7 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -2,14 +2,90 @@ from snek.system.service import BaseService\n \n \n class DriveService(BaseService):\n-    \n+\n     mapper_name = \"drive\"\n \n-    EXTENSIONS_PICTURES = [\"jpg\",\"jpeg\",\"png\",\"gif\",\"svg\",\"webp\",\"tiff\"]\n-    EXTENSIONS_VIDEOS = [\"mp4\",\"m4v\",\"mov\",\"wmv\",\"webm\",\"mkv\",\"mpg\",\"mpeg\",\"avi\",\"ogv\",\"ogg\",\"flv\",\"3gp\",\"3g2\"]\n-    EXTENSIONS_ARCHIVES = [\"zip\",\"rar\",\"7z\",\"tar\",\"tar.gz\",\"tar.xz\",\"tar.bz2\",\"tar.lzma\",\"tar.lz\"]\n-    EXTENSIONS_AUDIO = [\"mp3\",\"wav\",\"ogg\",\"flac\",\"m4a\",\"wma\",\"aac\",\"opus\",\"aiff\",\"au\",\"mid\",\"midi\"]\n-    EXTENSIONS_DOCS = [\"pdf\",\"doc\",\"docx\",\"xls\",\"xlsx\",\"ppt\",\"pptx\",\"txt\",\"md\",\"json\",\"csv\",\"xml\",\"html\",\"css\",\"js\",\"py\",\"sql\",\"rs\",\"toml\",\"yml\",\"yaml\",\"ini\",\"conf\",\"config\",\"log\",\"csv\",\"tsv\",\"java\",\"cs\",\"csproj\",\"scss\",\"less\",\"sass\",\"json\",\"lock\",\"lock.json\",\"jsonl\"]\n+    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n+    EXTENSIONS_VIDEOS = [\n+        \"mp4\",\n+        \"m4v\",\n+        \"mov\",\n+        \"wmv\",\n+        \"webm\",\n+        \"mkv\",\n+        \"mpg\",\n+        \"mpeg\",\n+        \"avi\",\n+        \"ogv\",\n+        \"ogg\",\n+        \"flv\",\n+        \"3gp\",\n+        \"3g2\",\n+    ]\n+    EXTENSIONS_ARCHIVES = [\n+        \"zip\",\n+        \"rar\",\n+        \"7z\",\n+        \"tar\",\n+        \"tar.gz\",\n+        \"tar.xz\",\n+        \"tar.bz2\",\n+        \"tar.lzma\",\n+        \"tar.lz\",\n+    ]\n+    EXTENSIONS_AUDIO = [\n+        \"mp3\",\n+        \"wav\",\n+        \"ogg\",\n+        \"flac\",\n+        \"m4a\",\n+        \"wma\",\n+        \"aac\",\n+        \"opus\",\n+        \"aiff\",\n+        \"au\",\n+        \"mid\",\n+        \"midi\",\n+    ]\n+    EXTENSIONS_DOCS = [\n+        \"pdf\",\n+        \"doc\",\n+        \"docx\",\n+        \"xls\",\n+        \"xlsx\",\n+        \"ppt\",\n+        \"pptx\",\n+        \"txt\",\n+        \"md\",\n+        \"json\",\n+        \"csv\",\n+        \"xml\",\n+        \"html\",\n+        \"css\",\n+        \"js\",\n+        \"py\",\n+        \"sql\",\n+        \"rs\",\n+        \"toml\",\n+        \"yml\",\n+        \"yaml\",\n+        \"ini\",\n+        \"conf\",\n+        \"config\",\n+        \"log\",\n+        \"csv\",\n+        \"tsv\",\n+        \"java\",\n+        \"cs\",\n+        \"csproj\",\n+        \"scss\",\n+        \"less\",\n+        \"sass\",\n+        \"json\",\n+        \"lock\",\n+        \"lock.json\",\n+        \"jsonl\",\n+    ]\n \n     async def get_drive_name_by_extension(self, extension):\n         if extension.startswith(\".\"):\n@@ -26,54 +102,52 @@ class DriveService(BaseService):\n             return \"Documents\"\n         return \"My Drive\"\n \n-    async def get_drive_by_extension(self,user_uid, extension):\n+    async def get_drive_by_extension(self, user_uid, extension):\n         name = await self.get_drive_name_by_extension(extension)\n-        return await self.get_or_create(user_uid=user_uid,name=name)\n+        return await self.get_or_create(user_uid=user_uid, name=name)\n \n-    async def get_by_user(self, user_uid,name=None):\n-        kwargs = dict(\n-            user_uid = user_uid\n-        )\n+    async def get_by_user(self, user_uid, name=None):\n+        kwargs = {\"user_uid\": user_uid}\n         async for model in self.find(**kwargs):\n             if not name:\n-                yield model \n-            elif model['name'] == name:\n                 yield model\n-            elif not model['name'] and name == 'My Drive':\n-                model['name'] = 'My Drive'\n+            elif model[\"name\"] == name:\n+                yield model\n+            elif not model[\"name\"] and name == \"My Drive\":\n+                model[\"name\"] = \"My Drive\"\n                 await self.save(model)\n-                yield model \n+                yield model\n \n-    async def get_or_create(self, user_uid,name=None,extensions=None):\n-        kwargs = dict(user_uid=user_uid)\n+    async def get_or_create(self, user_uid, name=None, extensions=None):\n+        kwargs = {\"user_uid\": user_uid}\n         if name:\n-            kwargs['name'] = name\n+            kwargs[\"name\"] = name\n         async for model in self.get_by_user(**kwargs):\n-            return model  \n+            return model\n \n         model = await self.new()\n-        model['user_uid'] = user_uid\n-        model['name'] = name \n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n         await self.save(model)\n-        return model \n+        return model\n \n     async def prepare_default_drives(self):\n         async for drive_item in self.services.drive_item.find():\n             extension = drive_item.extension\n-            drive = await self.get_drive_by_extension(drive_item['user_uid'],extension)\n-            if not drive_item['drive_uid'] == drive['uid']:\n-                drive_item['drive_uid'] = drive['uid']\n+            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n+            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n+                drive_item[\"drive_uid\"] = drive[\"uid\"]\n                 await self.services.drive_item.save(drive_item)\n-            \n+\n     async def prepare_default_drives_for_user(self, user_uid):\n-        await self.get_or_create(user_uid=user_uid,name=\"My Drive\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Shared Drive\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Pictures\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Videos\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Archives\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Documents\")\n+        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n \n     async def prepare_all(self):\n         await self.prepare_default_drives()\n         async for user in self.services.user.find():\n-            await self.prepare_default_drives_for_user(user['uid']) \n+            await self.prepare_default_drives_for_user(user[\"uid\"])\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 05a7da8..ce747c1 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,19 +1,19 @@\n-from snek.system.service import BaseService \n+from snek.system.service import BaseService\n \n \n class DriveItemService(BaseService):\n \n     mapper_name = \"drive_item\"\n \n-    async def create(self, drive_uid, name, path, type_,size):\n-        model = await self.new() \n-        model['drive_uid'] = drive_uid \n-        model['name'] = name \n-        model['path'] = str(path) \n-        model['extension'] = str(name).split(\".\")[-1]\n-        model['file_type'] = type_ \n-        model['file_size'] = size\n+    async def create(self, drive_uid, name, path, type_, size):\n+        model = await self.new()\n+        model[\"drive_uid\"] = drive_uid\n+        model[\"name\"] = name\n+        model[\"path\"] = str(path)\n+        model[\"extension\"] = str(name).split(\".\")[-1]\n+        model[\"file_type\"] = type_\n+        model[\"file_size\"] = size\n         if await self.save(model):\n-            return model \n+            return model\n         errors = await model.errors\n         raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 1392044..a22e8ae 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,5 +1,6 @@\n-from snek.system.service import BaseService\n from snek.system.model import now\n+from snek.system.service import BaseService\n+\n \n class NotificationService(BaseService):\n     mapper_name = \"notification\"\n@@ -7,13 +8,16 @@ class NotificationService(BaseService):\n     async def mark_as_read(self, user_uid, channel_message_uid):\n         model = await self.get(user_uid, object_uid=channel_message_uid)\n         if not model:\n-            return False \n-        model['read_at'] = now()\n+            return False\n+        model[\"read_at\"] = now()\n         await self.save(model)\n-        return True \n-    \n-    async def get_unread_stats(self,user_uid):\n-        records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))\n+        return True\n+\n+    async def get_unread_stats(self, user_uid):\n+        await self.query(\n+            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n+            {\"user_uid\": user_uid},\n+        )\n \n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n@@ -37,10 +41,10 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            if not channel_member['new_count']:\n-                channel_member['new_count'] = 0\n-            channel_member['new_count'] += 1\n-            \n+            if not channel_member[\"new_count\"]:\n+                channel_member[\"new_count\"] = 0\n+            channel_member[\"new_count\"] += 1\n+\n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if not usr:\n                 continue\n@@ -55,7 +59,7 @@ class NotificationService(BaseService):\n             )\n             try:\n                 await self.save(model)\n-            except Exception as ex:\n+            except Exception:\n                 raise Exception(f\"Failed to create notification: {model.errors}.\")\n \n         self.app.db.commit()\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex d941321..072a86f 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,6 +1,4 @@\n from snek.model.user import UserModel\n-\n-\n from snek.system.service import BaseService\n \n \n@@ -9,28 +7,27 @@ class SocketService(BaseService):\n     class Socket:\n         def __init__(self, ws, user: UserModel):\n             self.ws = ws\n-            self.is_connected = True \n-            self.user = user \n+            self.is_connected = True\n+            self.user = user\n \n         async def send_json(self, data):\n             if not self.is_connected:\n-                return False \n+                return False\n             try:\n                 await self.ws.send_json(data)\n             except Exception as ex:\n-                print(ex,flush=True)\n+                print(ex, flush=True)\n                 self.is_connected = False\n-            return True \n+            return True\n \n         async def close(self):\n             if not self.is_connected:\n-                return True \n-            \n+                return True\n+\n             await self.ws.close()\n             self.is_connected = False\n-            \n-            return True  \n \n+            return True\n \n     def __init__(self, app):\n         super().__init__(app)\n@@ -42,32 +39,31 @@ class SocketService(BaseService):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n         if not self.users.get(user_uid):\n-            self.users[user_uid] = set() \n+            self.users[user_uid] = set()\n         self.users[user_uid].add(s)\n \n-    async def subscribe(self, ws,channel_uid, user_uid):\n+    async def subscribe(self, ws, channel_uid, user_uid):\n         return\n-        if not channel_uid in self.subscriptions:\n+        if channel_uid not in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n-        s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.subscriptions[channel_uid].add(s)\n \n     async def send_to_user(self, user_uid, message):\n-        count = 0 \n-        for s in self.users.get(user_uid,[]):\n+        count = 0\n+        for s in self.users.get(user_uid, []):\n             if await s.send_json(message):\n-               count += 1 \n-        return count  \n+                count += 1\n+        return count\n \n     async def broadcast(self, channel_uid, message):\n-        count = 0\n-        async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n-            await self.send_to_user(channel_member[\"user_uid\"],message)\n-        return True \n-    \n+        async for channel_member in self.app.services.channel_member.find(\n+            channel_uid=channel_uid\n+        ):\n+            await self.send_to_user(channel_member[\"user_uid\"], message)\n+        return True\n+\n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n             self.sockets.remove(s)\n-            \n-    \ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 02707b0..6055403 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,16 +1,18 @@\n+import pathlib\n+\n from snek.system import security\n from snek.system.service import BaseService\n \n \n class UserService(BaseService):\n     mapper_name = \"user\"\n-    \n+\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\n             raise []\n         results = []\n-        async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):\n+        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)\n         return results\n \n@@ -23,17 +25,32 @@ class UserService(BaseService):\n         return True\n \n     async def save(self, user):\n-        if not user['color']:\n-            user['color'] = await self.services.util.random_light_hex_color()\n+        if not user[\"color\"]:\n+            user[\"color\"] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+    async def authenticate(self, username, password):\n+        print(username, password, flush=True)\n+        success = await self.validate_login(username, password)\n+        print(success, flush=True)\n+        if not success:\n+            return None\n+\n+        model = await self.get(username=username, deleted_at=None)\n+        return model\n+\n+    async def get_home_folder(self, user_uid):\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        if not folder.exists():\n+            folder.mkdir(parents=True, exist_ok=True)\n+        return folder\n \n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n         model[\"nick\"] = username\n-        model['color'] = await self.services.util.random_light_hex_color()\n+        model[\"color\"] = await self.services.util.random_light_hex_color()\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex 5550a8c..b620d9c 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,15 +1,14 @@\n import random\n \n-\n from snek.system.service import BaseService\n \n \n class UtilService(BaseService):\n-    \n+\n     async def random_light_hex_color(self):\n-   \n+\n         r = random.randint(128, 255)\n         g = random.randint(128, 255)\n         b = random.randint(128, 255)\n-        \n\\ No newline at end of file\n+\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex cd9484d..e97fdc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -20,13 +20,13 @@ class Cache:\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n             return None\n         self.lru.insert(0, args)\n         while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n         return self.cache[args]\n \n     def json_default(self, value):\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n \n     async def delete(self, args):\n         if args in self.cache:\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex ca603d8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-from html import escape\n+\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n@@ -24,21 +24,21 @@ class MarkdownRenderer(HTMLRenderer):\n     def _escape(self, str):\n \n-    def get_lexer(self, lang, default='bash'):\n+    def get_lexer(self, lang, default=\"bash\"):\n         try:\n             return get_lexer_by_name(lang, stripall=True)\n         except:\n             return get_lexer_by_name(default, stripall=True)\n-    \n+\n     def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n-            lang = 'bash'\n+            lang = \"bash\"\n         lexer = self.get_lexer(lang)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n-        return result \n+        return result\n \n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 2946262..1a6c7e6 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -20,7 +20,9 @@ async def no_cors_middleware(request, handler):\n async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = (\n+        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n+    )\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response\n@@ -28,17 +30,12 @@ async def cors_allow_middleware(request, handler):\n \n @web.middleware\n async def cors_middleware(request, handler):\n-    if request.method == \"OPTIONS\":\n-        response = web.Response()\n-        response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Methods\"] = (\n-            \"GET, POST, PUT, DELETE, OPTIONS\"\n-        )\n-        response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-        return response\n+    if request.headers.get(\"Allow\"):\n+        return await handler(request)\n \n     response = await handler(request)\n+    if request.headers.get(\"Allow\"):\n+        return response\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex 193bbb7..e0e5542 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,24 +1,27 @@\n import cProfile\n import pstats\n-import sys \n+import sys\n+\n from aiohttp import web\n+\n profiler = None\n-import io \n+import io\n \n \n @web.middleware\n async def profile_middleware(request, handler):\n-    global profiler \n+    global profiler\n     if not profiler:\n         profiler = cProfile.Profile()\n     profiler.enable()\n     response = await handler(request)\n     profiler.disable()\n     stats = pstats.Stats(profiler, stream=sys.stdout)\n-    stats.sort_stats('cumulative')\n-    stats.print_stats() \n+    stats.sort_stats(\"cumulative\")\n+    stats.print_stats()\n     return response\n \n+\n async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n@@ -27,17 +30,17 @@ async def profiler_handler(request):\n     stats.print_stats()\n     return web.Response(text=output.getvalue())\n \n+\n class Profiler:\n \n     def __init__(self):\n-        global profiler \n+        global profiler\n         if profiler is None:\n             profiler = cProfile.Profile()\n         self.profiler = profiler\n \n     async def __aenter__(self):\n-        self.profiler.enable()    \n-    \n+        self.profiler.enable()\n+\n     async def __aexit__(self, *args, **kwargs):\n         self.profiler.disable()\n-\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d65f947..c6d2afc 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -58,7 +58,7 @@ class BaseService:\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n-        if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n             yield model\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 0e71b80..cff807b 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,15 +1,21 @@\n+import re\n from types import SimpleNamespace\n-from bs4 import BeautifulSoup\n-import re \n-import emoji\n \n+import emoji\n+from bs4 import BeautifulSoup\n from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n+    \"en\": \":snek1:\",\n+    \"status\": 2,\n+    \"E\": 0.6,\n+    \"alias\": [\":snek1:\"],\n+}\n \n-emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+emoji.EMOJI_DATA[\n+    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n@@ -64,62 +70,91 @@ emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\"\"\"] = {\"en\": \":a1:\",\"status\":2,\"E\":0.6, \"alias\":[\":a1:\"]}\n+\"\"\"\n+] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n+\n \n def set_link_target_blank(text):\n-    soup = BeautifulSoup(text, 'html.parser')\n+    soup = BeautifulSoup(text, \"html.parser\")\n \n-    for element in soup.find_all(\"a\"):  \n-        element.attrs['target'] = '_blank'\n-        element.attrs['rel'] = 'noopener noreferrer'\n-        element.attrs['referrerpolicy'] = 'no-referrer'\n-        element.attrs['href'] = element.attrs['href'].strip(\".\").strip(\",\")\n+    for element in soup.find_all(\"a\"):\n+        element.attrs[\"target\"] = \"_blank\"\n+        element.attrs[\"rel\"] = \"noopener noreferrer\"\n+        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n+        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n \n     return str(soup)\n \n+\n def embed_youtube(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        if (\n+            and \"?v=\" in element.attrs[\"href\"]\n+        ):\n             video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n+\n def embed_image(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n-        for extension in [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n-            if extension in element.attrs['href'].lower():\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".png\",\n+            \".jpg\",\n+            \".jpeg\",\n+            \".gif\",\n+            \".webp\",\n+            \".svg\",\n+            \".bmp\",\n+            \".tiff\",\n+            \".ico\",\n+            \".heif\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n                 embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n+\n def embed_media(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n-        for extension in [\".mp4\", \".mp3\", \".wav\", \".ogg\", \".webm\", \".flac\", \".aac\",\".mpg\",\".avi\",\".wmv\"]:\n-            if extension in element.attrs['href'].lower():\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".mp4\",\n+            \".mp3\",\n+            \".wav\",\n+            \".ogg\",\n+            \".webm\",\n+            \".flac\",\n+            \".aac\",\n+            \".mpg\",\n+            \".avi\",\n+            \".wmv\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n                 embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n-                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n \n-\n def linkify_https(text):\n-        return text \n+        return text\n \n-    soup = BeautifulSoup(text, 'html.parser')\n+    soup = BeautifulSoup(text, \"html.parser\")\n \n-    for element in soup.find_all(text=True):  \n+    for element in soup.find_all(text=True):\n         parent = element.parent\n-        if parent.name in ['a', 'script', 'style']:  \n+        if parent.name in [\"a\", \"script\", \"style\"]:\n             continue\n-        \n+\n         new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n-        element.replace_with(BeautifulSoup(new_text, 'html.parser'))\n+        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n \n     return set_link_target_blank(str(soup))\n \n@@ -140,8 +175,7 @@ class EmojiExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return emoji.emojize(caller(),language='alias')\n-\n+        return emoji.emojize(caller(), language=\"alias\")\n \n \n class LinkifyExtension(Extension):\n@@ -170,6 +204,7 @@ class LinkifyExtension(Extension):\n         result = embed_youtube(result)\n         return result\n \n+\n class PythonExtension(Extension):\n     tags = {\"py3\"}\n \n@@ -186,26 +221,26 @@ class PythonExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        \n+\n         def fn(source):\n-            import subprocess \n-            import subprocess \n-            import pathlib \n-            from pathlib import Path \n-            import os \n-            import sys \n-            import requests\n+            import subprocess\n+\n             def system(command):\n                 if isinstance(command):\n                     command = command.split(\" \")\n-                from io import StringIO \n+                from io import StringIO\n+\n                 stdout = StringIO()\n-                subprocess.run(command,stderr=stdout,stdout=stdout,text=True)\n+                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n                 return stdout.getvalue()\n+\n             to_write = []\n+\n             def render(text):\n-                global to_write \n+                global to_write\n                 to_write.append(text)\n+\n             exec(source)\n             return \"\".join(to_write)\n+\n         return str(fn(caller()))\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 80dda17..4d3781e 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,30 +1,27 @@\n import asyncio\n-import aiohttp\n-import aiohttp.web\n import os\n import pty\n-import shlex\n import subprocess\n-import pathlib\n \n commands = {\n-    'alpine': 'docker run -it alpine /bin/sh',\n-    'r': 'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh',\n+    \"alpine\": \"docker run -it alpine /bin/sh\",\n+    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n }\n \n+\n class TerminalSession:\n-    def __init__(self,command):\n+    def __init__(self, command):\n         self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.history = b''\n-        self.history_size = 1024*20\n+        self.sockets = []\n+        self.history = b\"\"\n+        self.history_size = 1024 * 20\n         self.process = subprocess.Popen(\n             command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n             stderr=self.slave,\n             bufsize=0,\n-            universal_newlines=True\n+            universal_newlines=True,\n         )\n \n     async def add_websocket(self, ws):\n@@ -35,11 +32,11 @@ class TerminalSession:\n         if len(self.sockets) > 1 and self.history:\n             start = 0\n             try:\n-                start = self.history.index(b'\\n')\n+                start = self.history.index(b\"\\n\")\n             except ValueError:\n-                pass \n+                pass\n             await ws.send_bytes(self.history[start:])\n-            return \n+            return\n         loop = asyncio.get_event_loop()\n         while True:\n             try:\n@@ -48,9 +45,10 @@ class TerminalSession:\n                     break\n                 self.history += data\n                 if len(self.history) > self.history_size:\n-                    self.history = self.history[:0-self.history_size]\n+                    self.history = self.history[: 0 - self.history_size]\n                 try:\n+                    for ws in self.sockets:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 2765642..4a6e7a1 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -12,9 +12,9 @@ class BaseView(web.View):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \n-    @property \n+    @property\n     def base_url(self):\n-        return str(self.request.url.with_path('').with_query(''))\n+        return str(self.request.url.with_path(\"\").with_query(\"\"))\n \n     @property\n     def app(self):\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex b03f922..aba57ae 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -26,12 +26,14 @@\n \n from snek.system.view import BaseView\n \n+\n class AboutHTMLView(BaseView):\n-   \n+\n     async def get(self):\n         return await self.render_template(\"about.html\")\n \n+\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex cbd973c..a85b876 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -23,11 +23,14 @@\n-from multiavatar import multiavatar\n import uuid\n+\n from aiohttp import web\n+from multiavatar import multiavatar\n+\n from snek.system.view import BaseView\n \n+\n class AvatarView(BaseView):\n     login_required = False\n \n@@ -36,6 +39,6 @@ class AvatarView(BaseView):\n         if uid == \"unique\":\n             uid = str(uuid.uuid4())\n         avatar = multiavatar.multiavatar(uid, True, None)\n-        response = web.Response(text=avatar, content_type='image/svg+xml')\n-        response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n+        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n         return response\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex 592d1a2..bb63413 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,35 +1,37 @@\n-  \n-  \n-  \n-  \n-  \n+\n+\n+\n+\n+\n from snek.system.view import BaseView\n \n+\n class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n \n+\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 1026cf7..853cdb2 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,6 +1,8 @@\n-from snek.system.view import BaseView\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class DriveView(BaseView):\n \n     login_required = True\n@@ -14,21 +16,22 @@ class DriveView(BaseView):\n             drive_items = []\n             async for item in drive.items:\n                 record = item.record\n-                record['url'] = '/drive.bin/' + record['uid'] + '.' + item.extension\n+                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n                 drive_items.append(record)\n             return web.json_response(drive_items)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n-        \n         drives = []\n-        async for drive in self.services.drive.get_by_user(user['uid']):\n+        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n             record = drive.record\n-            record['items'] = []\n+            record[\"items\"] = []\n             async for item in drive.items:\n                 drive_item_record = item.record\n-                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + item.extension\n-                record['items'].append(item.record)\n+                drive_item_record[\"url\"] = (\n+                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n+                )\n+                record[\"items\"].append(item.record)\n             drives.append(record)\n-        \n+\n         return web.json_response(drives)\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 3e62518..6ad6e70 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -12,6 +12,7 @@\n \n from snek.system.view import BaseView\n \n+\n class IndexView(BaseView):\n     async def get(self):\n-        return await self.render_template(\"index.html\")\n\\ No newline at end of file\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 5028a7a..be79328 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -7,9 +7,11 @@\n \n from aiohttp import web\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n+\n class LoginView(BaseFormView):\n     form = LoginForm\n \n@@ -18,17 +20,23 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\", {\"form\": await self.form(app=self.app).to_json()})\n+        return await self.render_template(\n+            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            user = await self.services.user.get(username=form['username'], deleted_at=None)\n+            user = await self.services.user.get(\n+                username=form[\"username\"], deleted_at=None\n+            )\n             await self.services.user.save(user)\n-            self.session.update({\n-                \"logged_in\": True,\n-                \"username\": user['username'],\n-                \"uid\": user[\"uid\"],\n-                \"color\": user[\"color\"]\n-            })\n+            self.session.update(\n+                {\n+                    \"logged_in\": True,\n+                    \"username\": user[\"username\"],\n+                    \"uid\": user[\"uid\"],\n+                    \"color\": user[\"color\"],\n+                }\n+            )\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 230d334..acf7c75 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -20,4 +20,4 @@ class LoginFormView(BaseFormView):\n             self.session[\"username\"] = form.username.value\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 57b92a3..42016d8 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -15,10 +15,10 @@\n@@ -29,6 +29,7 @@\n \n \n from aiohttp import web\n+\n from snek.system.view import BaseView\n \n \n@@ -52,4 +53,4 @@ class LogoutView(BaseView):\n             del self.session[\"username\"]\n         except KeyError:\n             pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n\\ No newline at end of file\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 6e49506..7fbce9d 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -2,14 +2,16 @@\n \n \n \n \n from aiohttp import web\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterView(BaseFormView):\n     form = RegisterForm\n \n@@ -18,16 +20,20 @@ class RegisterView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"register.html\", {\"form\": await self.form(app=self.app).to_json()})\n+        return await self.render_template(\n+            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n \n     async def submit(self, form):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session.update({\n-            \"uid\": result[\"uid\"],\n-            \"username\": result[\"username\"],\n-            \"logged_in\": True,\n-            \"color\": result[\"color\"]\n-        })\n-        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 5ce42a7..7b98647 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -2,7 +2,7 @@\n \n \n \n@@ -13,10 +13,10 @@\n@@ -28,6 +28,7 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n@@ -35,10 +36,12 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session.update({\n-            \"uid\": result[\"uid\"],\n-            \"username\": result[\"username\"],\n-            \"logged_in\": True,\n-            \"color\": result[\"color\"]\n-        })\n-        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 27a8dcf..19c98d4 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -7,41 +7,44 @@\n \n \n-from aiohttp import web  \n-from snek.system.view import BaseView\n-import traceback\n import json\n-from snek.system.model import now \n+import traceback\n+\n+from aiohttp import web\n+\n+from snek.system.model import now\n from snek.system.profiler import Profiler\n+from snek.system.view import BaseView\n+\n \n class RPCView(BaseView):\n \n     class RPCApi:\n         def __init__(self, view, ws):\n-            self.view = view \n+            self.view = view\n             self.app = self.view.app\n             self.services = self.app.services\n-            self.ws = ws \n+            self.ws = ws\n \n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")\n \n-        @property \n+        @property\n         def request(self):\n-            return self.view.request \n-        \n+            return self.view.request\n+\n         def _require_login(self):\n             if not self.is_logged_in:\n                 raise Exception(\"Not logged in\")\n \n-        @property \n+        @property\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n         async def mark_as_read(self, channel_uid):\n             self._require_login()\n-            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid) \n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n             return True\n \n         async def login(self, username, password):\n@@ -54,16 +57,26 @@ class RPCView(BaseView):\n             self.view.session[\"username\"] = user[\"username\"]\n             self.view.session[\"user_nick\"] = user[\"nick\"]\n             record = user.record\n-            del record['password']\n-            del record['deleted_at']\n-            await self.services.socket.add(self.ws,self.view.request.session.get('uid'))\n-            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"], self.view.request.session.get(\"uid\"))\n-            return record \n-\n-        async def search_user(self, query): \n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            await self.services.socket.add(\n+                self.ws, self.view.request.session.get(\"uid\")\n+            )\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.view.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    self.ws,\n+                    subscription[\"channel_uid\"],\n+                    self.view.request.session.get(\"uid\"),\n+                )\n+            return record\n+\n+        async def search_user(self, query):\n             self._require_login()\n-            return [user['username'] for user in await self.services.user.search(query)]\n+            return [user[\"username\"] for user in await self.services.user.search(query)]\n \n         async def get_user(self, user_uid):\n             self._require_login()\n@@ -71,46 +84,56 @@ class RPCView(BaseView):\n                 user_uid = self.user_uid\n             user = await self.services.user.get(uid=user_uid)\n             record = user.record\n-            del record['password']\n-            del record['deleted_at']\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n             if user_uid != user[\"uid\"]:\n-                del record['email']\n-            return record \n+                del record[\"email\"]\n+            return record\n \n-        async def get_messages(self, channel_uid, offset=0,timestamp = None):\n+        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n             self._require_login()\n             messages = []\n-            for message in await self.services.channel_message.offset(channel_uid, offset or 0,timestamp or None):\n-                extended_dict = await self.services.channel_message.to_extended_dict(message)\n+            for message in await self.services.channel_message.offset(\n+                channel_uid, offset or 0, timestamp or None\n+            ):\n+                extended_dict = await self.services.channel_message.to_extended_dict(\n+                    message\n+                )\n                 messages.append(extended_dict)\n             return messages\n \n         async def get_channels(self):\n             self._require_login()\n             channels = []\n-            async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n-                channel = await self.services.channel.get(uid=subscription['channel_uid'])\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.user_uid, is_banned=False\n+            ):\n+                channel = await self.services.channel.get(\n+                    uid=subscription[\"channel_uid\"]\n+                )\n                 last_message = await channel.get_last_message()\n-                color = None \n+                color = None\n                 if last_message:\n                     last_message_user = await last_message.get_user()\n-                    color = last_message_user['color']\n-                channels.append({\n-                    \"name\": subscription[\"label\"],\n-                    \"uid\": subscription[\"channel_uid\"],\n-                    \"tag\": channel[\"tag\"],\n-                    \"new_count\": subscription[\"new_count\"],\n-                    \"is_moderator\": subscription[\"is_moderator\"],\n-                    \"is_read_only\": subscription[\"is_read_only\"],\n-                    'new_count': subscription['new_count'],\n-                    'color': color \n-                })\n+                    color = last_message_user[\"color\"]\n+                channels.append(\n+                    {\n+                        \"name\": subscription[\"label\"],\n+                        \"uid\": subscription[\"channel_uid\"],\n+                        \"tag\": channel[\"tag\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"is_moderator\": subscription[\"is_moderator\"],\n+                        \"is_read_only\": subscription[\"is_read_only\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"color\": color,\n+                    }\n+                )\n             return channels\n \n         async def send_message(self, channel_uid, message):\n             self._require_login()\n             await self.services.chat.send(self.user_uid, channel_uid, message)\n-            return True \n+            return True\n \n         async def echo(self, *args):\n             self._require_login()\n@@ -118,29 +141,47 @@ class RPCView(BaseView):\n \n         async def query(self, *args):\n             self._require_login()\n-            query = args[0] \n+            query = args[0]\n             lowercase = query.lower()\n-            if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n+            if (\n+                any(\n+                    keyword in lowercase\n+                    for keyword in [\n+                        \"drop\",\n+                        \"alter\",\n+                        \"update\",\n+                        \"delete\",\n+                        \"replace\",\n+                        \"insert\",\n+                        \"truncate\",\n+                    ]\n+                )\n+                and \"select\" not in lowercase\n+            ):\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) async for record in self.services.channel.query(args[0])]\n+            records = [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n             for record in records:\n                 try:\n-                    del record['email']\n+                    del record[\"email\"]\n                 except KeyError:\n-                    pass \n+                    pass\n                 try:\n                     del record[\"password\"]\n                 except KeyError:\n-                    pass \n+                    pass\n                 try:\n-                    del record['message']\n+                    del record[\"message\"]\n                 except:\n                     pass\n                 try:\n-                    del record['html']\n-                except: \n+                    del record[\"html\"]\n+                except:\n                     pass\n-            return [dict(record) async for record in self.services.channel.query(args[0])]\n+            return [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n \n         async def __call__(self, data):\n             try:\n@@ -150,30 +191,43 @@ class RPCView(BaseView):\n                     raise Exception(\"Not allowed\")\n                 args = data.get(\"args\") or []\n                 if hasattr(super(), method_name) or not hasattr(self, method_name):\n-                    return await self._send_json({\"callId\": call_id, \"data\": \"Not allowed\"})\n+                    return await self._send_json(\n+                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n+                    )\n                 method = getattr(self, method_name.replace(\".\", \"_\"), None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n-                success = True \n+                success = True\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False\n                 if result != \"noresponse\":\n-                    await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n+                    await self._send_json(\n+                        {\"callId\": call_id, \"success\": success, \"data\": result}\n+                    )\n             except Exception as ex:\n                 print(str(ex), flush=True)\n-                await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n+                await self._send_json(\n+                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n+                )\n \n         async def _send_json(self, obj):\n             await self.ws.send_str(json.dumps(obj, default=str))\n-    \n \n         async def get_online_users(self, channel_uid):\n             self._require_login()\n-            \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_online_users(channel_uid)\n+            ]\n \n         async def echo(self, obj):\n             await self.ws.send_json(obj)\n@@ -182,12 +236,20 @@ class RPCView(BaseView):\n         async def get_users(self, channel_uid):\n             self._require_login()\n \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_users(channel_uid)]\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_users(channel_uid)\n+            ]\n \n         async def ping(self, callId, *args):\n             if self.user_uid:\n                 user = await self.services.user.get(uid=self.user_uid)\n-                user['last_ping'] = now()\n+                user[\"last_ping\"] = now()\n                 await self.services.user.save(user)\n             return {\"pong\": args}\n \n@@ -196,8 +258,14 @@ class RPCView(BaseView):\n         await ws.prepare(self.request)\n         if self.request.session.get(\"logged_in\"):\n             await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\"))\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n+                )\n         rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\n@@ -209,7 +277,7 @@ class RPCView(BaseView):\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n-                pass \n+                pass\n             elif msg.type == web.WSMsgType.CLOSE:\n-                pass \n+                pass\n         return ws\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 347d3b2..d97a4b6 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -8,17 +8,17 @@\n \n@@ -28,7 +28,6 @@\n \n \n-from aiohttp import web\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n \n@@ -40,14 +39,17 @@ class SearchUserView(BaseFormView):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n-            users = [user.record for user in  await self.app.services.user.search(query)]\n+            users = [user.record for user in await self.app.services.user.search(query)]\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n         current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or '','current_user': current_user})\n+        return await self.render_template(\n+            \"search_user.html\",\n+            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n+        )\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n+            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n         return {\"is_valid\": False}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 117942a..4675572 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -25,17 +25,18 @@\n \n from snek.system.view import BaseView\n \n+\n class StatusView(BaseView):\n     async def get(self):\n         memberships = []\n         user = {}\n-        \n+\n         user_id = self.session.get(\"uid\")\n         if user_id:\n             user = await self.app.services.user.get(uid=user_id)\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n-            \n+\n             async for model in self.app.services.channel_member.find(\n                 user_uid=user_id, deleted_at=None, is_banned=False\n             ):\n@@ -69,4 +70,4 @@ class StatusView(BaseView):\n                     self.app.cache.cache, None\n                 ),\n             }\n-        )\n\\ No newline at end of file\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 6596f2c..d3af9b0 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,15 +1,17 @@\n-from snek.system.view import BaseView \n-import aiohttp \n-import asyncio\n-from snek.system.terminal import TerminalSession\n import pathlib\n \n+import aiohttp\n+\n+from snek.system.terminal import TerminalSession\n+from snek.system.view import BaseView\n+\n+\n class TerminalSocketView(BaseView):\n-    \n+\n     login_required = True\n \n     user_sessions = {}\n-    \n+\n     async def prepare_drive(self):\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n@@ -19,40 +21,34 @@ class TerminalSocketView(BaseView):\n             destination_path = root.joinpath(path.name)\n             if not path.is_dir():\n                 destination_path.write_bytes(path.read_bytes())\n-        return root \n-        \n-    async def get(self):\n-       \n+        return root\n \n+    async def get(self):\n \n         ws = aiohttp.web.WebSocketResponse()\n         await ws.prepare(self.request)\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = await self.prepare_drive()\n-        \n-\n-        \n \n         command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:\n             self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n-        session = self.user_sessions[user[\"uid\"]]   \n+        session = self.user_sessions[user[\"uid\"]]\n         await session.add_websocket(ws)\n \n         async for msg in ws:\n             if msg.type == aiohttp.WSMsgType.BINARY:\n                 await session.write_input(msg.data.decode())\n \n-        \n         return ws\n \n+\n class TerminalView(BaseView):\n \n     login_required = True\n \n     async def get(self):\n-        request = self.request\n-        return await self.request.app.render_template('terminal.html',self.request)\n+        return await self.request.app.render_template(\"terminal.html\", self.request)\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 4f3fe8c..bc923c6 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,5 +1,6 @@\n from snek.system.view import BaseView\n \n+\n class ThreadsView(BaseView):\n \n     async def get(self):\n@@ -12,22 +13,25 @@ class ThreadsView(BaseView):\n             if not last_message:\n                 continue\n \n-            thread[\"uid\"] = channel['uid']\n+            thread[\"uid\"] = channel[\"uid\"]\n             thread[\"name\"] = await channel_member.get_name()\n             thread[\"new_count\"] = channel_member[\"new_count\"]\n             thread[\"last_message_on\"] = channel[\"last_message_on\"]\n-            thread['created_at'] = thread['last_message_on']\n+            thread[\"created_at\"] = thread[\"last_message_on\"]\n \n-            \n             thread[\"last_message_text\"] = last_message[\"message\"]\n-            thread['last_message_user_uid'] = last_message[\"user_uid\"]\n-            user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n-            if channel['tag'] == \"dm\":\n-                thread['name_color'] = user_last_message['color']\n-            thread['last_message_user_color'] = user_last_message['color']  \n+            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(\n+                uid=last_message[\"user_uid\"]\n+            )\n+            if channel[\"tag\"] == \"dm\":\n+                thread[\"name_color\"] = user_last_message[\"color\"]\n+            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n             threads.append(thread)\n-            \n-        threads.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n-        return await self.render_template(\"threads.html\", dict(threads=threads,user=user))\n+        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+\n+        return await self.render_template(\n+            \"threads.html\", {\"threads\": threads, \"user\": user}\n+        )\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 8884d72..b32d94e 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,31 +1,36 @@\n \n \n \n \n-from snek.system.view import BaseView\n-import aiofiles\n import pathlib\n-from aiohttp import web\n import uuid\n \n+import aiofiles\n+from aiohttp import web\n+\n+from snek.system.view import BaseView\n+\n UPLOAD_DIR = pathlib.Path(\"./drive\")\n \n+\n class UploadView(BaseView):\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n         response = web.FileResponse(drive_item[\"path\"])\n-        response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n-        response.headers['Content-Disposition'] = f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        )\n         return response\n \n     async def post(self):\n@@ -36,7 +41,9 @@ class UploadView(BaseView):\n \n         channel_uid = None\n \n-        drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n+        drive = await self.services.drive.get_or_create(\n+            user_uid=self.request.session.get(\"uid\")\n+        )\n \n         extension_types = {\n             \".jpg\": \"image\",\n@@ -47,7 +54,7 @@ class UploadView(BaseView):\n             \".mp3\": \"audio\",\n             \".pdf\": \"document\",\n             \".doc\": \"document\",\n-            \".docx\": \"document\"\n+            \".docx\": \"document\",\n         }\n \n         while field := await reader.next():\n@@ -58,32 +65,45 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n-            \n+\n             name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n             file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n             files.append(file_path)\n \n-            async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n+            async with aiofiles.open(str(file_path.absolute()), \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n             drive_item = await self.services.drive_item.create(\n-                drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n+                drive[\"uid\"],\n+                filename,\n+                str(file_path.absolute()),\n+                file_path.stat().st_size,\n+                file_path.suffix,\n             )\n-           \n-            type_ = \"unknown\"\n+\n             extension = \".\" + filename.split(\".\")[-1]\n             if extension in extension_types:\n-                type_ = extension_types[extension] \n-            \n+                extension_types[extension]\n+\n             await self.services.drive_item.save(drive_item)\n-            response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            response = \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            response = (\n+                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            )\n+            response = (\n+                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            )\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response\n             )\n \n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [str(file) for file in files],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex b9271c3..111f76c 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -25,37 +25,55 @@\n \n from aiohttp import web\n+\n from snek.system.view import BaseView\n \n+\n class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n         if self.login_required and not self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/\")\n-        channel = await self.services.channel.get(uid=self.request.match_info.get(\"channel\"))\n+        channel = await self.services.channel.get(\n+            uid=self.request.match_info.get(\"channel\")\n+        )\n         if not channel:\n-            user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+            user = await self.services.user.get(\n+                uid=self.request.match_info.get(\"channel\")\n+            )\n             if user:\n-                channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+                channel = await self.services.channel.get_dm(\n+                    self.session.get(\"uid\"), user[\"uid\"]\n+                )\n                 if channel:\n                     return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n-        \n-        channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n+\n+        channel_member = await self.app.services.channel_member.get(\n+            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n+        )\n         if not channel_member:\n             return web.HTTPNotFound()\n-        \n-        channel_member['new_count'] = 0\n+\n+        channel_member[\"new_count\"] = 0\n         await self.app.services.channel_member.save(channel_member)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n-            channel[\"uid\"]\n-        )]\n+        messages = [\n+            await self.app.services.channel_message.to_extended_dict(message)\n+            for message in await self.app.services.channel_message.offset(\n+                channel[\"uid\"]\n+            )\n+        ]\n         for message in messages:\n-            await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n+            await self.app.services.notification.mark_as_read(\n+                self.session.get(\"uid\"), message[\"uid\"]\n+            )\n \n         name = await channel_member.get_name()\n-        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages})\n+        return await self.render_template(\n+            \"web.html\",\n+            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n+        )\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex 27e1b4b..aab17e4 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,7 +1,9 @@\n import asyncio\n-import asyncssh\n-import os\n import logging\n+import os\n+\n+import asyncssh\n+\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n@@ -11,6 +13,7 @@ PASSWORD = \"woeii\"\n HOST = \"localhost\"\n PORT = 2225\n \n+\n class MySFTPServer(asyncssh.SFTPServer):\n     def __init__(self, chan):\n         super().__init__(chan)\n@@ -31,8 +34,10 @@ class MySFTPServer(asyncssh.SFTPServer):\n         full_path = os.path.join(self.root, path.lstrip(\"/\"))\n         return await super().listdir(full_path)\n \n+\n class MySSHServer(asyncssh.SSHServer):\n     \"\"\"Custom SSH server to handle authentication\"\"\"\n+\n     def connection_made(self, conn):\n         print(f\"New connection from {conn.get_extra_info('peername')}\")\n \n@@ -46,11 +51,12 @@ class MySSHServer(asyncssh.SSHServer):\n \n     def validate_password(self, username, password):\n-        print(username,password)\n-        \n+        print(username, password)\n+\n         return True\n         return username == USERNAME and password == PASSWORD\n \n+\n async def start_sftp_server():\n \n@@ -59,11 +65,12 @@ async def start_sftp_server():\n         host=HOST,\n         port=PORT,\n         server_host_keys=[\"ssh_host_key\"],\n-        process_factory=MySFTPServer\n+        process_factory=MySFTPServer,\n     )\n     print(f\"SFTP server running on {HOST}:{PORT}\")\n \n+\n if __name__ == \"__main__\":\n     try:\n         asyncio.run(start_sftp_server())\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 1bfec21..2fa26a7 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,7 +1,8 @@\n import asyncio\n-import asyncssh\n import os\n \n+import asyncssh\n+\n HOST = \"0.0.0.0\"\n PORT = 2225\n@@ -9,6 +10,7 @@ USERNAME = \"user\"\n PASSWORD = \"password\"\n \n+\n class CustomSSHServer(asyncssh.SSHServer):\n     def connection_made(self, conn):\n         print(f\"New connection from {conn.get_extra_info('peername')}\")\n@@ -22,6 +24,7 @@ class CustomSSHServer(asyncssh.SSHServer):\n     def validate_password(self, username, password):\n         return username == USERNAME and password == PASSWORD\n \n+\n async def custom_bash_process(process):\n     \"\"\"Spawns a custom bash shell process\"\"\"\n     env = os.environ.copy()\n@@ -29,7 +32,12 @@ async def custom_bash_process(process):\n \n     bash_proc = await asyncio.create_subprocess_exec(\n-        SHELL, \"-i\", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env\n+        SHELL,\n+        \"-i\",\n+        stdin=asyncio.subprocess.PIPE,\n+        stdout=asyncio.subprocess.PIPE,\n+        stderr=asyncio.subprocess.PIPE,\n+        env=env,\n     )\n \n     async def read_output():\n@@ -48,6 +56,7 @@ async def custom_bash_process(process):\n \n     await asyncio.gather(read_output(), read_input())\n \n+\n async def start_ssh_server():\n     \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n     await asyncssh.create_server(\n@@ -55,14 +64,14 @@ async def start_ssh_server():\n         host=HOST,\n         port=PORT,\n         server_host_keys=[\"ssh_host_key\"],\n-        process_factory=custom_bash_process\n+        process_factory=custom_bash_process,\n     )\n     print(f\"SSH server running on {HOST}:{PORT}\")\n \n+\n if __name__ == \"__main__\":\n     try:\n         asyncio.run(start_ssh_server())\n     except (OSError, asyncssh.Error) as e:\n         print(f\"Error starting SSH server: {e}\")\n-\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex d50cc54..4a09452 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -27,45 +27,48 @@\n \n-import asyncio, asyncssh, sys\n-\n-async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-\n+import asyncio\n+import sys\n \n+import asyncssh\n \n \n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n \n     width, height, pixwidth, pixheight = process.term_size\n \n-    process.stdout.write(f'Terminal type: {process.term_type}, '\n-                         f'size: {width}x{height}')\n+    process.stdout.write(\n+        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n+    )\n     if pixwidth and pixheight:\n-        process.stdout.write(f' ({pixwidth}x{pixheight} pixels)')\n-    process.stdout.write('\\nTry resizing your window!\\n')\n+        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n+    process.stdout.write(\"\\nTry resizing your window!\\n\")\n \n     while not process.stdin.at_eof():\n         try:\n             await process.stdin.read()\n         except asyncssh.TerminalSizeChanged as exc:\n-            process.stdout.write(f'New window size: {exc.width}x{exc.height}')\n+            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n             if exc.pixwidth and exc.pixheight:\n-                process.stdout.write(f' ({exc.pixwidth}'\n-                                     f'x{exc.pixheight} pixels)')\n-            process.stdout.write('\\n')\n-\n-\n+                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n+            process.stdout.write(\"\\n\")\n \n \n async def start_server() -> None:\n-    await asyncssh.listen('', 2230, server_host_keys=['ssh_host_key'],\n-                          process_factory=handle_client)\n+    await asyncssh.listen(\n+        \"\",\n+        2230,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex e54480f..187722c 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -24,32 +24,39 @@\n \n-import asyncio, asyncssh, bcrypt, sys\n+import asyncio\n+import sys\n from typing import Optional\n \n-             'user': bcrypt.hashpw(b'user', bcrypt.gensalt()),\n-            }\n+import asyncssh\n+import bcrypt\n+\n+passwords = {\n+    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n+}\n+\n \n def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-    username = process.get_extra_info('username')\n-    process.stdout.write(f'Welcome to my SSH server, {username}!\\n')\n+    username = process.get_extra_info(\"username\")\n+    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n+\n \n class MySSHServer(asyncssh.SSHServer):\n     def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n-        peername = conn.get_extra_info('peername')[0]\n-        print(f'SSH connection received from {peername}.')\n+        peername = conn.get_extra_info(\"peername\")[0]\n+        print(f\"SSH connection received from {peername}.\")\n \n     def connection_lost(self, exc: Optional[Exception]) -> None:\n         if exc:\n-            print('SSH connection error: ' + str(exc), file=sys.stderr)\n+            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n         else:\n-            print('SSH connection closed.')\n+            print(\"SSH connection closed.\")\n \n     def begin_auth(self, username: str) -> bool:\n-        return passwords.get(username) != b''\n+        return passwords.get(username) != b\"\"\n \n     def password_auth_supported(self) -> bool:\n         return True\n@@ -60,18 +67,24 @@ class MySSHServer(asyncssh.SSHServer):\n         pw = passwords[username]\n         if not password and not pw:\n             return True\n-        return bcrypt.checkpw(password.encode('utf-8'), pw)\n+        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n+\n \n async def start_server() -> None:\n-    await asyncssh.create_server(MySSHServer, '', 2231,\n-                                 server_host_keys=['ssh_host_key'],\n-                                 process_factory=handle_client)\n+    await asyncssh.create_server(\n+        MySSHServer,\n+        \"\",\n+        2231,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex bbaf3d3..cfd5d21 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -27,11 +27,15 @@\n \n-import asyncio, asyncssh, sys\n+import asyncio\n+import sys\n from typing import List, cast\n \n+import asyncssh\n+\n+\n class ChatClient:\n-    _clients: List['ChatClient'] = []\n+    _clients: List[\"ChatClient\"] = []\n \n     def __init__(self, process: asyncssh.SSHServerProcess):\n         self._process = process\n@@ -40,8 +44,6 @@ class ChatClient:\n     async def handle_client(cls, process: asyncssh.SSHServerProcess):\n         await cls(process).run()\n \n-    \n-\n     async def readline(self) -> str:\n         return cast(str, self._process.stdin.readline())\n \n@@ -53,54 +55,58 @@ class ChatClient:\n             if client != self:\n                 client.write(msg)\n \n-\n     def begin_auth(self, username: str) -> bool:\n \n     def password_auth_supported(self) -> bool:\n         return True\n \n     def validate_password(self, username: str, password: str) -> bool:\n         return True\n-\n \n     async def run(self) -> None:\n-        self.write('Welcome to chat!\\n\\n')\n+        self.write(\"Welcome to chat!\\n\\n\")\n \n-        self.write('Enter your name: ')\n-        name = (await self.readline()).rstrip('\\n')\n+        self.write(\"Enter your name: \")\n+        name = (await self.readline()).rstrip(\"\\n\")\n \n-        self.write(f'\\n{len(self._clients)} other users are connected.\\n\\n')\n+        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n \n         self._clients.append(self)\n-        self.broadcast(f'*** {name} has entered chat ***\\n')\n+        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n \n         try:\n             async for line in self._process.stdin:\n-                self.broadcast(f'{name}: {line}')\n+                self.broadcast(f\"{name}: {line}\")\n         except asyncssh.BreakReceived:\n             pass\n \n-        self.broadcast(f'*** {name} has left chat ***\\n')\n+        self.broadcast(f\"*** {name} has left chat ***\\n\")\n         self._clients.remove(self)\n \n+\n async def start_server() -> None:\n-    await asyncssh.listen('', 2235, server_host_keys=['ssh_host_key'],\n-                          process_factory=ChatClient.handle_client)\n+    await asyncssh.listen(\n+        \"\",\n+        2235,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=ChatClient.handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Initial implementation of WebDAV functionality with basic operations", "commit": "9a923f6bddd73df27af80ef6c8e2313816a07a48", "diff": "commit 9a923f6bddd73df27af80ef6c8e2313816a07a48\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:15:53 2025 +0100\n\n    WEebdav.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nnew file mode 100644\nindex 0000000..b28b04f\n--- /dev/null\n+++ b/src/snek/__init__.py\n@@ -0,0 +1,3 @@\n+\n+\n+\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nnew file mode 100644\nindex 0000000..30f0209\n--- /dev/null\n+++ b/src/snek/__main__.py\n@@ -0,0 +1,5 @@\n+from aiohttp import web \n+from snek.app import Application\n+\n+if __name__ == '__main__':\n+    web.run_app(Application(), port=8081,host='0.0.0.0')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nnew file mode 100755\nindex 0000000..66aeb3d\n--- /dev/null\n+++ b/src/snek/webdav.py\n@@ -0,0 +1,348 @@\n+import logging\n+\n+import pathlib\n+logging.basicConfig(level=logging.DEBUG)\n+\n+import asyncio\n+import base64\n+import datetime\n+import mimetypes\n+import os\n+import shutil\n+import uuid\n+\n+import aiofiles\n+import aiohttp\n+import aiohttp.web\n+from lxml import etree\n+\n+\n+class WebdavApplication(aiohttp.web.Application):\n+    def __init__(self, parent, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.locks = {}\n+        \n+        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n+        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n+        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n+        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n+        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n+        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n+        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n+        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n+        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n+        self.parent = parent\n+\n+    @property \n+    def db(self):\n+        return self.parent.db\n+\n+    @property \n+    def services(self):\n+        return self.parent.services \n+\n+\n+    async def authenticate(self, request):\n+        session = request.session\n+        if session.get('uid'):\n+            request['user'] = await self.services.user.get(uid=session['uid'])\n+            try:\n+                request['home'] = await self.services.user.get_home_folder(user_uid=request['user']['uid'])\n+            except:\n+                pass\n+            return user\n+\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return False\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request['user'] = await self.services.user.authenticate(username=username, password=password)\n+        try:\n+            request['home'] = await self.services.user.get_home_folder(request['user']['uid'])\n+        except Exception as ex:\n+            print(ex)\n+            pass\n+        return request['user']\n+\n+    async def handle_get(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request['home'] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+\n+        return aiohttp.web.FileResponse(\n+            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n+        )\n+\n+    async def handle_put(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path.parent.mkdir(parents=True, exist_ok=True)\n+        async with aiofiles.open(file_path, \"wb\") as f:\n+            while chunk := await request.content.read(1024):\n+                await f.write(chunk)\n+        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n+\n+    async def handle_delete(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request['home'] / request.match_info[\"filename\"]\n+        if file_path.is_file():\n+            file_path.unlink()\n+            return aiohttp.web.Response(status=204)\n+        elif file_path.is_dir():\n+            shutil.rmtree(file_path)\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=404, text=\"Not found\")\n+\n+    async def handle_mkcol(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        dir_path = request['home'] / request.match_info[\"filename\"]\n+        if dir_path.exists():\n+            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n+        dir_path.mkdir(parents=True, exist_ok=True)\n+        return aiohttp.web.Response(status=201, text=\"Directory created\")\n+\n+    async def handle_move(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request['home'] / request.match_info[\"filename\"]\n+        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        shutil.move(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n+\n+    async def handle_copy(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request['home'] / request.match_info[\"filename\"]\n+        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        if src_path.is_file():\n+            shutil.copy2(str(src_path), str(dest_path))\n+        else:\n+            shutil.copytree(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n+\n+    async def handle_options(self, request):\n+        headers = {\n+            \"DAV\": \"1, 2\",\n+            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n+        }\n+        print(\"RETURN\")\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+    def get_current_utc_time(self, filepath):\n+        if filepath.exists():\n+            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n+        else:\n+            modified_time = datetime.datetime.utcnow()\n+        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n+            \"%a, %d %b %Y %H:%M:%S GMT\"\n+        )\n+\n+    def get_directory_size(self, directory):\n+        total_size = 0\n+        for dirpath, _, filenames in os.walk(directory):\n+            for f in filenames:\n+                fp = pathlib.Path(dirpath) / f\n+                if fp.exists():\n+                    total_size += fp.stat().st_size\n+        return total_size\n+\n+    def get_disk_free_space(self, path):\n+        statvfs = os.statvfs(path)\n+        return statvfs.f_bavail * statvfs.f_frsize\n+\n+    async def handle_propfind(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        requested_path = request.match_info.get(\"filename\", \"\") \n+        abs_path = request['home'] / requested_path\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n+        nsmap = {\"D\": \"DAV:\"}\n+        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n+        directories = [requested_path]\n+        if abs_path.is_dir():\n+            directories.extend(os.listdir(abs_path))\n+        for item in directories:\n+            full_path = abs_path / item if item != requested_path else abs_path\n+            href_path = f\"/{requested_path}/{item}/\" if item != requested_path else f\"/{requested_path}/\"\n+            response = etree.SubElement(response_xml, \"{DAV:}response\")\n+            href = etree.SubElement(response, \"{DAV:}href\")\n+            if not full_path.is_dir():\n+                href_path = href_path.rstrip(\"/\")\n+            href.text = href_path\n+            propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+            prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+            res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+            if full_path.is_dir():\n+                etree.SubElement(res_type, \"{DAV:}collection\")\n+            creation_date, last_modified = self.get_current_utc_time(full_path)\n+            etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+            etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n+            etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+                self.get_disk_free_space(request['home'])\n+            )\n+            etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+            etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+            etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+            mimetype, _ = mimetypes.guess_type(full_path.name)\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n+            supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+            lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+            lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+            etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+            lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+            etree.SubElement(lock_type_1, \"{DAV:}write\")\n+            lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+            lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+            etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+            lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+            etree.SubElement(lock_type_2, \"{DAV:}write\")\n+            etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        xml_output = etree.tostring(\n+            response_xml, encoding=\"utf-8\", xml_declaration=True\n+        ).decode()\n+        return aiohttp.web.Response(\n+            status=207, text=xml_output, content_type=\"application/xml\"\n+        )\n+\n+    async def handle_proppatch(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n+\n+    async def handle_lock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_id = str(uuid.uuid4())\n+        self.locks[resource] = lock_id\n+        xml_response = self.generate_lock_response(lock_id)\n+        headers = {\n+            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n+            \"Content-Type\": \"application/xml\",\n+        }\n+        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n+\n+    async def handle_unlock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n+            \"opaquelocktoken:\", \"\"\n+        )\n+        if self.locks.get(resource) == lock_token:\n+            del self.locks[resource]\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n+\n+    def generate_lock_response(self, lock_id):\n+        nsmap = {\"D\": \"DAV:\"}\n+        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n+        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n+        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n+        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type, \"{DAV:}write\")\n+        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n+        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n+        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n+        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n+        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n+        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n+        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n+        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n+\n+    def get_last_modified(self, path):\n+        if not path.exists():\n+            return None\n+        timestamp = path.stat().st_mtime\n+        dt = datetime.datetime.utcfromtimestamp(timestamp)\n+        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n+\n+    async def handle_head(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        print(requested_path) \n+        abs_path = request['home'] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(\n+                status=403, text=\"Cannot get metadata for a directory\"\n+            )\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+        file_size = abs_path.stat().st_size\n+\n+        headers = {\n+            \"Content-Type\": content_type,\n+            \"Content-Length\": str(file_size),\n+            \"Last-Modified\": self.get_last_modified(abs_path),\n+        }\n+\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented WebDAV authentication and home folder retrieval", "commit": "886d21999c716ee306318796f3f159ba085f9618", "diff": "commit 886d21999c716ee306318796f3f159ba085f9618\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:19:08 2025 +0100\n\n    Added webdav.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 66aeb3d..3033e3a 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -45,14 +45,14 @@ class WebdavApplication(aiohttp.web.Application):\n \n \n     async def authenticate(self, request):\n-        session = request.session\n-        if session.get('uid'):\n-            request['user'] = await self.services.user.get(uid=session['uid'])\n-            try:\n-                request['home'] = await self.services.user.get_home_folder(user_uid=request['user']['uid'])\n-            except:\n-                pass\n-            return user\n \n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Use `/home` instead of `/drive` for user home folders", "commit": "6dca3a96e1cd23cc26d69aeee31ae45e14977bbd", "diff": "commit 6dca3a96e1cd23cc26d69aeee31ae45e14977bbd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:27:56 2025 +0100\n\n    Added webdav.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6055403..70e8adf 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -40,7 +40,7 @@ class UserService(BaseService):\n         return model\n \n     async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        folder = pathlib.Path(f\"./home/{user_uid}\")\n         if not folder.exists():\n             folder.mkdir(parents=True, exist_ok=True)\n         return folder"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implement node creation and enhance propfind with resource details", "commit": "3926b2d837bef181546d826b41821cf90fc755a8", "diff": "commit 3926b2d837bef181546d826b41821cf90fc755a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 10:50:40 2025 +0100\n\n    Update storage.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 3033e3a..e03e675 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -188,11 +188,74 @@ class WebdavApplication(aiohttp.web.Application):\n         statvfs = os.statvfs(path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n+\n+    async def create_node(self, request, response_xml, full_path, depth):\n+        requested_path = request.match_info.get(\"filename\", \"\") \n+        abs_path = pathlib.Path(full_path)\n+        relative_path = str(full_path.relative_to(request['home']))\n+\n+        href_path = f\"{relative_path}\".strip(\"/\")\n+        response = etree.SubElement(response_xml, \"{DAV:}response\")\n+        href = etree.SubElement(response, \"{DAV:}href\")\n+        href.text = href_path\n+        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+        if full_path.is_dir():\n+            etree.SubElement(res_type, \"{DAV:}collection\")\n+        creation_date, last_modified = self.get_current_utc_time(full_path)\n+        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+            full_path.stat().st_size\n+            if full_path.is_file()\n+            else self.get_directory_size(full_path)\n+        )\n+        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+            self.get_disk_free_space(request['home'])\n+        )\n+        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+        mimetype, _ = mimetypes.guess_type(full_path.name)\n+        etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+        etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+            full_path.stat().st_size\n+            if full_path.is_file()\n+            else self.get_directory_size(full_path)\n+        )\n+        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_1, \"{DAV:}write\")\n+        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_2, \"{DAV:}write\")\n+        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        \n+        if abs_path.is_dir() and depth != -1:\n+            for item in abs_path.iterdir():\n+                await self.create_node(request,response_xml, item, depth - 1)\n+\n+\n+\n+\n     async def handle_propfind(self, request):\n         if not await self.authenticate(request):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n+\n+        depth = 0\n+        try:\n+            depth = int(request.headers.get(\"Depth\", \"0\"))\n+        except ValueError:\n+            pass\n         requested_path = request.match_info.get(\"filename\", \"\") \n         abs_path = request['home'] / requested_path\n         if not abs_path.exists():\n@@ -200,54 +263,9 @@ class WebdavApplication(aiohttp.web.Application):\n         nsmap = {\"D\": \"DAV:\"}\n         response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n         directories = [requested_path]\n-        if abs_path.is_dir():\n-            directories.extend(os.listdir(abs_path))\n-        for item in directories:\n-            full_path = abs_path / item if item != requested_path else abs_path\n-            href_path = f\"/{requested_path}/{item}/\" if item != requested_path else f\"/{requested_path}/\"\n-            response = etree.SubElement(response_xml, \"{DAV:}response\")\n-            href = etree.SubElement(response, \"{DAV:}href\")\n-            if not full_path.is_dir():\n-                href_path = href_path.rstrip(\"/\")\n-            href.text = href_path\n-            propstat = etree.SubElement(response, \"{DAV:}propstat\")\n-            prop = etree.SubElement(propstat, \"{DAV:}prop\")\n-            res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n-            if full_path.is_dir():\n-                etree.SubElement(res_type, \"{DAV:}collection\")\n-            creation_date, last_modified = self.get_current_utc_time(full_path)\n-            etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n-            etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-                full_path.stat().st_size\n-                if full_path.is_file()\n-                else self.get_directory_size(full_path)\n-            )\n-            etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-                self.get_disk_free_space(request['home'])\n-            )\n-            etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n-            etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n-            etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n-            mimetype, _ = mimetypes.guess_type(full_path.name)\n-            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                full_path.stat().st_size\n-                if full_path.is_file()\n-                else self.get_directory_size(full_path)\n-            )\n-            supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n-            lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-            lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n-            etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n-            lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n-            etree.SubElement(lock_type_1, \"{DAV:}write\")\n-            lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-            lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n-            etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n-            lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n-            etree.SubElement(lock_type_2, \"{DAV:}write\")\n-            etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        \n+        await self.create_node(request, response_xml, abs_path, depth)\n+\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Updated home folder path to drive and removed lock management", "commit": "d5917b94540aee206935354f438a6a7f893278ec", "diff": "commit d5917b94540aee206935354f438a6a7f893278ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 06:54:25 2025 +0200\n\n    New version.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 70e8adf..6055403 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -40,7 +40,7 @@ class UserService(BaseService):\n         return model\n \n     async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./home/{user_uid}\")\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\n             folder.mkdir(parents=True, exist_ok=True)\n         return folder\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex e03e675..b823c19 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -287,7 +287,7 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n-        self.locks[resource] = lock_id\n         xml_response = self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\","}
{"repo": ".", "date": "2025-03-30", "line": "feat: Add missing LOCK and UNLOCK routes", "commit": "8058e4a4b0aee254edc76fa28b2c4336eb393c4b", "diff": "commit 8058e4a4b0aee254edc76fa28b2c4336eb393c4b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 07:02:35 2025 +0200\n\n    New version.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex b823c19..a372b8b 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -31,8 +31,8 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n-        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n-        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n         self.parent = parent\n \n     @property"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Handle potential errors when creating home folders", "commit": "2a47c0ba5e7344d44c3acd15d8f3efeb11722677", "diff": "commit 2a47c0ba5e7344d44c3acd15d8f3efeb11722677\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 09:34:13 2025 +0200\n\n    Fixed bug.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6055403..b70be63 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,7 +42,10 @@ class UserService(BaseService):\n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\n-            folder.mkdir(parents=True, exist_ok=True)\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n         return folder\n \n     async def register(self, email, username, password):"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added settings page with profile, notifications, and privacy sections", "commit": "32e0c959e8589f574d1c46b96d35f49c98721566", "diff": "commit 32e0c959e8589f574d1c46b96d35f49c98721566\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 16:21:29 2025 +0200\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 984fcf3..d043c0e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -42,6 +42,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n+from snek.view.settings import SettingsView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -137,6 +138,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\n+        self.router.add_view(\"/settings.html\", SettingsView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginView)\ndiff --git a/src/snek/templates/settings.html b/src/snek/templates/settings.html\nnew file mode 100644\nindex 0000000..cfb186c\n--- /dev/null\n+++ b/src/snek/templates/settings.html\n@@ -0,0 +1,36 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+{% include \"sidebar_settings.html\" %}\n+\n+{% endblock %}\n+\n+{% block head %}\n+\n+\n+{% endblock %}\n+\n+{% block main %}\n+\n+<h1>Setting page</h1>\n+\n+<div id=\"profile_description\"></div>\n+\n+\n+\n+<script type=\"module\">\n+\n+\n+        require(['vs/editor/editor.main'], function () {\n+var editor = monaco.editor.create(document.getElementById('profile_description'), {\n+                value: phpCode,\n+                language: 'php'\n+            });\n+        })\n+</script>\n+\n+{% endblock main %}\ndiff --git a/src/snek/templates/sidebar_settings.html b/src/snek/templates/sidebar_settings.html\nnew file mode 100644\nindex 0000000..8e18412\n--- /dev/null\n+++ b/src/snek/templates/sidebar_settings.html\n@@ -0,0 +1,14 @@\n+<style>\n+  .channel-list-item-highlight {\n+  }\n+</style>\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>Settings</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/settings.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/settings-notifications.html\">Notifications</a></li>\n+        <li><a class=\"no-select\" href=\"/settings-privacy.html\">Privacy</a></li> \n+    </ul>\n+\n+  </aside>\ndiff --git a/src/snek/view/settings.py b/src/snek/view/settings.py\nnew file mode 100644\nindex 0000000..fe181f2\n--- /dev/null\n+++ b/src/snek/view/settings.py\n@@ -0,0 +1,8 @@\n+from snek.system.view import BaseView \n+\n+class SettingsView(BaseView):\n+    \n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template('settings.html')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex a372b8b..9a2b9e4 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -219,8 +219,9 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n         etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n         mimetype, _ = mimetypes.guess_type(full_path.name)\n-        etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-        etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+        if full_path.is_file():\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n             full_path.stat().st_size\n             if full_path.is_file()\n             else self.get_directory_size(full_path)"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Improve responsiveness for smaller screens and reposition chat input", "commit": "87b48af551d2ed023f77ca57d033ed0079d303f3", "diff": "commit 87b48af551d2ed023f77ca57d033ed0079d303f3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:08:01 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 31a6e74..db9dcb0 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -347,3 +347,15 @@ a {\n }\n \n+@media only screen and (max-width: 600px) {\n+    header{\n+        position: sticky;\n+        display: block;\n+        .logo {\n+            display:block;\n+        }\n+    }\n+    .chat-input {\n+        position:sticky;\n+    }\n+}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 28ad4d2..d386b67 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -12,10 +12,10 @@\n         {% endfor %}\n     </div>\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n-    <div class=\"chat-input\">\n+    <footer class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </div>\n+    </footer>\n </section>\n \n <script type=\"module\">"}
{"repo": ".", "date": "2025-04-01", "line": "refactor: Reduced height of message list and container", "commit": "18b1ec20b67522cf816b29f3cde64a935ec5b330", "diff": "commit 18b1ec20b67522cf816b29f3cde64a935ec5b330\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:20:18 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex db9dcb0..da74695 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,7 +101,7 @@ a {\n \n .message-list {\n   flex: 1;\n-  height: 200px;\n+  height: 10px;\n   padding-bottom: 40px;\n   overflow-y: auto;\n }\n@@ -112,7 +112,7 @@ a {\n   scrollbar-width: none;\n   -ms-overflow-style: none;\n   padding: 10px;\n-  height: 200px;\n+  height: 10px;\n }"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Removed chat window element", "commit": "7c52c2d9d5f10623ccf479918ea5baed247a07b5", "diff": "commit 7c52c2d9d5f10623ccf479918ea5baed247a07b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:23:35 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d386b67..3c7bbf9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,7 +11,6 @@\n             {% endautoescape %}\n         {% endfor %}\n     </div>\n-    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <footer class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added footer styling for improved layout", "commit": "fbd4fa4e668628c11d8b592718cfcdcca71a3c0f", "diff": "commit fbd4fa4e668628c11d8b592718cfcdcca71a3c0f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:28:56 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex da74695..7a4617d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -99,6 +99,13 @@ a {\n \n }\n \n+footer {\n+    display: flex;\n+    justify-content: space-between;\n+    align-items: center;\n+    padding: 10px 20px;\n+}\n+\n .message-list {\n   flex: 1;\n   height: 10px;"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Responsive adjustments for mobile devices", "commit": "d24627b35fd2201e6baad781a11fbae0c379f366", "diff": "commit d24627b35fd2201e6baad781a11fbae0c379f366\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:06:07 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 319d7d3..be4b327 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -266,7 +266,6 @@ class GenericForm extends HTMLElement {\n     }\n \n     div {\n         border-radius: 10px;\n         padding: 30px;\n         width: 400px;\n@@ -429,4 +428,4 @@ class GenericForm extends HTMLElement {\n   }\n }\n \n-customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\n+customElements.define('generic-form', GenericForm);\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 0661225..f10f780 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -4,13 +4,16 @@\n   \n     box-sizing: border-box;\n   }\n+    \n+  body {\n+  }\n \n   .dialog {\n \n     border-radius: 10px;\n     padding: 30px;\n-    width: 800px;\n+    width: 600px;\n     margin: 30px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n }\n@@ -40,7 +43,6 @@ h2 {\n }\n body {\n     font-family: Arial, sans-serif;\n     line-height: 1.5;\n     display: flex;\n@@ -53,4 +55,4 @@ body {\n div {\n     text-align: left;\n \n-}\n\\ No newline at end of file\n+}\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex ffbd5bc..a1e8894 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -5,7 +5,14 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek chat by Molodetz</title>\n     <link rel=\"stylesheet\" href=\"generic-form.css\">\n-  <link rel=\"stylesheet\" href=\"register__.css\">\n+    <link rel=\"stylesheet\" href=\"base.css\">\n+<style>\n+  .registration-container {\n+        max-width: 300px;\n+        margin: 20px auto;\n+        padding: 20px;\n+  }\n+</style>\n     <script src=\"/fancy-button.js\"></script>\n </head>\n@@ -14,9 +21,11 @@\n     <h1>Snek</h1>\n     <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n     So Snek came through, lean and optimized.</p>\n+    <div style=\"text-align: center;\">\n     <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">OR</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n+    </div>\n   </div>\n </body>\n </html>\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex be79328..fe8cf4d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -15,6 +15,8 @@ from snek.system.view import BaseFormView\n class LoginView(BaseFormView):\n     form = LoginForm\n \n+    login_required = False\n+\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 7fbce9d..96eed8a 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -15,6 +15,8 @@ from snek.system.view import BaseFormView\n class RegisterView(BaseFormView):\n     form = RegisterForm\n \n+    login_required = False\n+\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Reduced container width for mobile responsiveness", "commit": "e3c997302b07228c0791d6307b429109b6eb3d53", "diff": "commit e3c997302b07228c0791d6307b429109b6eb3d53\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:09:58 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex f10f780..6e81345 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -13,7 +13,7 @@\n \n     border-radius: 10px;\n     padding: 30px;\n-    width: 600px;\n+    width: 500px;\n     margin: 30px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n }"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted form width for better mobile responsiveness", "commit": "27c0abea3147e5e00a5196fa9b351141a9d17ae2", "diff": "commit 27c0abea3147e5e00a5196fa9b351141a9d17ae2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:23:10 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nindex d593816..7010193 100644\n--- a/src/snek/static/generic-form.css\n+++ b/src/snek/static/generic-form.css\n@@ -14,6 +14,7 @@\n     justify-content: center;\n     align-items: center;\n     height: 100vh;\n+    width: 100vw;\n   }\n \n generic-form {\n@@ -29,7 +30,6 @@ generic-form {\n     border-radius: 10px;\n     padding: 30px;\n-    width: 400px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n     text-align: center;\n \n@@ -93,8 +93,7 @@ input {\n }\n \n \n-@media (max-width: 500px) {\n+@media (max-width: 600px) {\n     .generic-form-container {\n-        width: 90%;\n     }\n-}\n\\ No newline at end of file\n+}"}
{"repo": ".", "date": "2025-04-02", "line": "fix: Handle missing last message gracefully", "commit": "b365afc910522a484ad257af6df7b607712c71f2", "diff": "commit b365afc910522a484ad257af6df7b607712c71f2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 10:52:02 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 183ddb0..0a90c39 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -13,12 +13,15 @@ class ChannelModel(BaseModel):\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n     async def get_last_message(self) -> ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\n-            \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n-            {\"channel_uid\": self[\"uid\"]},\n-        ):\n+        try:\n+            async for model in self.app.services.channel_message.query(\n+                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+                {\"channel_uid\": self[\"uid\"]},\n+            ):\n \n-            return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+        except:\n+            pass\n         return None\n \n     async def get_members(self):"}
{"repo": ".", "date": "2025-04-02", "line": "feat: Add UUID to context and style updates for smaller screens", "commit": "99b2beeab0d55242537b7ec810bfdd69feb47103", "diff": "commit 99b2beeab0d55242537b7ec810bfdd69feb47103\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 14:55:18 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex d043c0e..c6c2e2f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,7 +2,7 @@ import asyncio\n import logging\n import pathlib\n import time\n-\n+import uuid\n from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n@@ -189,6 +189,8 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n+        \n+        context['rid'] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n                 user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7a4617d..049241d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -42,6 +42,7 @@ header {\n   padding-top: 10px;\n   padding-left: 20px;\n   padding-right: 20px;\n+    padding-bottom: 10px;\n   display: flex;\n   justify-content: space-between;\n   align-items: center;\n@@ -79,6 +80,17 @@ a {\n   margin-bottom: 3px;\n }\n \n+h1 {\n+    font-size: 2em;\n+}\n+\n+h2 {\n+    font-size: 1.4em;\n+}\n+\n+\n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -354,7 +366,31 @@ a {\n }\n \n-@media only screen and (max-width: 600px) {\n+@media only screen and (max-width: 768px) {\n+    \n+    header{\n+        position:fixed;\n+        top: 0;\n+        left: 0;\n+            text-overflow: ellipsis;\n+\n+        *{\n+            font-size: 12px !important;\n+        }\n+        .logo {\n+            text-overflow: ellipsis;\n+            white-space: nowrap;\n+            overflow: hidden;\n+            h2 {\n+                font-size: 12px;\n+            }\n+        }\n+        \n+    }\n+    body {\n+        justify-content: flex-start;\n+    }\n     header{\n         position: sticky;\n         display: block;\n@@ -364,5 +400,5 @@ a {\n     }\n     .chat-input {\n         position:sticky;\n-    }\n }\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nindex 7010193..025bfb3 100644\n--- a/src/snek/static/generic-form.css\n+++ b/src/snek/static/generic-form.css\n@@ -34,11 +34,11 @@ generic-form {\n     text-align: center;\n \n }\n-\n .generic-form-container h1 {\n     font-size: 2em;\n     margin-bottom: 20px;\n+\n }\n input {\n \ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex be4b327..f2edacb 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -266,9 +266,9 @@ class GenericForm extends HTMLElement {\n     }\n \n     div {\n+        min-width:350px;\n         border-radius: 10px;\n         padding: 30px;\n-        width: 400px;\n         box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n         text-align: center;\n     }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 6e81345..af04db4 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -56,3 +56,9 @@ div {\n     text-align: left;\n \n }\n+\n+@media screen and (max-width: 500px) {\n+    body {\n+\n+    justify-content: flex-start;\n+    }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 1533153..6c3f137 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -21,11 +21,12 @@\n </head>\n <body>\n+\n+\n+\n   <header>\n-    <div class=\"no-select logo\" style=\"display:none\">Snek</div>\n-    \n-    <div class=\"logo\">{% block header_text %}{% endblock %}</div>\n-    <nav class=\"no-select\">\n+    <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n+    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n@@ -39,6 +40,7 @@\n     {% block sidebar %}\n     {% include \"sidebar_channels.html\" %}\n     {% endblock %}\n+    \n     {% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\n     {% endblock %}\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d2f8de7..2d2620e 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -16,10 +16,10 @@\n     <script src=\"/app.js\" type=\"module\"></script>\n     <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n-    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <link rel=\"stylesheet\" href=\"/style.css?rid={{ rid }}\">\n     <script src=\"/fancy-button.js\" type=\"module\"></script>\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n-    <script src=\"/generic-form.js\" type=\"module\"></script>\n+    <script src=\"/generic-form.js?rid={{ rid }}\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n     {% block head %}\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 66371f1..bf3cab1 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,10 +4,12 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n     <h2 class=\"no-select\">Terminals</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n     </ul>\n     {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3c7bbf9..fa5e03e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,9 +1,14 @@\n {% extends \"app.html\" %}\n \n-{% block header_text %}<h2>{{ name }}</h2>{% endblock %} \n \n {% block main %}\n-<section class=\"chat-area\" id=\"chat\">\n+\n+\n+\n+\n+\n+<section class=\"chat-area\">\n     <div class=\"chat-messages\">\n         {% for message in messages %}\n             {% autoescape false %}\n@@ -11,10 +16,10 @@\n             {% endautoescape %}\n         {% endfor %}\n     </div>\n-    <footer class=\"chat-input\">\n+    <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </footer>\n+    </div>\n </section>\n \n <script type=\"module\">\n@@ -137,6 +142,8 @@\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+             const inputBox = document.querySelector(\".chat-input\");\n+             inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }"}
{"repo": ".", "date": "2025-04-02", "line": "style: Adjusted navigation styling for better overflow handling", "commit": "81479e7058feda9954fd74810d1294fb92e7a1c4", "diff": "commit 81479e7058feda9954fd74810d1294fb92e7a1c4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 15:02:55 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 049241d..7384ad7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -373,7 +373,7 @@ a {\n         top: 0;\n         left: 0;\n             text-overflow: ellipsis;\n-\n+        width:100%;\n         *{\n             font-size: 12px !important;\n         }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 6c3f137..5ab68c4 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -26,7 +26,7 @@\n \n   <header>\n     <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n-    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n+    <nav class=\"no-select\" style=\"overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Refactor settings view and sidebar", "commit": "d10768403d221fbd1d50a520c083b8d1be1b3a19", "diff": "commit d10768403d221fbd1d50a520c083b8d1be1b3a19\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 3 08:34:25 2025 +0200\n\n    Progress.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex b28b04f..8b13789 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -1,3 +1 @@\n \n-\n-\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 30f0209..692ad68 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,5 +1,6 @@\n-from aiohttp import web \n+from aiohttp import web\n+\n from snek.app import Application\n \n-if __name__ == '__main__':\n-    web.run_app(Application(), port=8081,host='0.0.0.0')\n+if __name__ == \"__main__\":\n+    web.run_app(Application(), port=8081, host=\"0.0.0.0\")\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c6c2e2f..25913b9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,6 +3,7 @@ import logging\n import pathlib\n import time\n import uuid\n+\n from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n@@ -24,7 +25,7 @@ from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import cors_middleware\n+from snek.system.middleware import auth_middleware, cors_middleware\n from snek.system.profiler import profiler_handler\n from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -37,12 +38,13 @@ from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n+from snek.view.settings.index import SettingsIndexView\n+from snek.view.settings.profile import SettingsProfileView\n from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n-from snek.view.settings import SettingsView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -76,6 +78,7 @@ class Application(BaseApplication):\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self.tasks = asyncio.Queue()\n         self._middlewares.append(session_middleware)\n+        self._middlewares.append(auth_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n@@ -138,7 +141,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\n-        self.router.add_view(\"/settings.html\", SettingsView)\n+        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n+        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n+        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginView)\n@@ -189,8 +194,8 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-        \n-        context['rid'] = str(uuid.uuid4())\n+\n+        context[\"rid\"] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n                 user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 96053ea..2d4b12c 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -7,6 +7,7 @@ from snek.mapper.drive import DriveMapper\n from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n+from snek.mapper.user_property import UserPropertyMapper\n from snek.system.object import Object\n \n \n@@ -21,6 +22,7 @@ def get_mappers(app=None):\n             \"notification\": NotificationMapper(app=app),\n             \"drive_item\": DriveItemMapper(app=app),\n             \"drive\": DriveMapper(app=app),\n+            \"user_property\": UserPropertyMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex c87d39c..a1009a5 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -5,7 +5,11 @@ from snek.model.channel_member import ChannelMemberModel\n \n from snek.model.channel_message import ChannelMessageModel\n+from snek.model.drive import DriveModel\n+from snek.model.drive_item import DriveItemModel\n+from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n+from snek.model.user_property import UserPropertyModel\n from snek.system.object import Object\n \n \n@@ -17,6 +21,10 @@ def get_models():\n             \"channel_member\": ChannelMemberModel,\n             \"channel\": ChannelModel,\n             \"channel_message\": ChannelMessageModel,\n+            \"drive_item\": DriveItemModel,\n+            \"drive\": DriveModel,\n+            \"notification\": NotificationModel,\n+            \"user_property\": UserPropertyModel,\n         }\n     )\n \ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 1a6c7e6..3a9a055 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -28,6 +28,16 @@ async def cors_allow_middleware(request, handler):\n     return response\n \n \n+@web.middleware\n+async def auth_middleware(request, handler):\n+    request[\"user\"] = None\n+    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n+        request[\"user\"] = await request.app.services.user.get(\n+            uid=request.app.session.get(\"uid\")\n+        )\n+    return await handler(request)\n+\n+\n @web.middleware\n async def cors_middleware(request, handler):\n     if request.headers.get(\"Allow\"):\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 5ab68c4..b05eb21 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -31,7 +31,7 @@\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/settings/index.html\">\u2699\ufe0f</a>\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n \ndiff --git a/src/snek/templates/settings.html b/src/snek/templates/settings.html\nindex cfb186c..1ceb629 100644\n--- a/src/snek/templates/settings.html\n+++ b/src/snek/templates/settings.html\n@@ -13,24 +13,14 @@\n \n {% endblock %}\n \n-{% block main %}\n-\n+{% block logo %}\n <h1>Setting page</h1>\n \n-<div id=\"profile_description\"></div>\n-\n+{% endblock %}\n \n+{% block main %}\n \n-<script type=\"module\">\n \n \n-        require(['vs/editor/editor.main'], function () {\n-var editor = monaco.editor.create(document.getElementById('profile_description'), {\n-                value: phpCode,\n-                language: 'php'\n-            });\n-        })\n-</script>\n \n {% endblock main %}\ndiff --git a/src/snek/templates/sidebar_settings.html b/src/snek/templates/sidebar_settings.html\ndeleted file mode 100644\nindex 8e18412..0000000\n--- a/src/snek/templates/sidebar_settings.html\n+++ /dev/null\n@@ -1,14 +0,0 @@\n-<style>\n-  .channel-list-item-highlight {\n-  }\n-</style>\n-<aside class=\"sidebar\" id=\"channelSidebar\">\n-    <h2>Settings</h2>\n-    <ul>\n-        <li><a class=\"no-select\" href=\"/settings.html\">Profile</a></li>\n-        <li><a class=\"no-select\" href=\"/settings-notifications.html\">Notifications</a></li>\n-        <li><a class=\"no-select\" href=\"/settings-privacy.html\">Privacy</a></li> \n-    </ul>\n-\n-  </aside>\ndiff --git a/src/snek/view/settings.py b/src/snek/view/settings.py\ndeleted file mode 100644\nindex fe181f2..0000000\n--- a/src/snek/view/settings.py\n+++ /dev/null\n@@ -1,8 +0,0 @@\n-from snek.system.view import BaseView \n-\n-class SettingsView(BaseView):\n-    \n-    login_required = True\n-\n-    async def get(self):\n-        return await self.render_template('settings.html')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 9a2b9e4..0068fcc 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,9 +1,8 @@\n import logging\n-\n import pathlib\n+\n logging.basicConfig(level=logging.DEBUG)\n \n-import asyncio\n import base64\n import datetime\n import mimetypes\n@@ -21,7 +20,7 @@ class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n         super().__init__(*args, **kwargs)\n         self.locks = {}\n-        \n+\n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n         self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n@@ -31,22 +30,21 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n         self.parent = parent\n \n-    @property \n+    @property\n     def db(self):\n         return self.parent.db\n \n-    @property \n+    @property\n     def services(self):\n-        return self.parent.services \n-\n+        return self.parent.services\n \n     async def authenticate(self, request):\n@@ -60,13 +58,17 @@ class WebdavApplication(aiohttp.web.Application):\n         encoded_creds = auth_header.split(\"Basic \")[1]\n         decoded_creds = base64.b64decode(encoded_creds).decode()\n         username, password = decoded_creds.split(\":\", 1)\n-        request['user'] = await self.services.user.authenticate(username=username, password=password)\n+        request[\"user\"] = await self.services.user.authenticate(\n+            username=username, password=password\n+        )\n         try:\n-            request['home'] = await self.services.user.get_home_folder(request['user']['uid'])\n+            request[\"home\"] = await self.services.user.get_home_folder(\n+                request[\"user\"][\"uid\"]\n+            )\n         except Exception as ex:\n             print(ex)\n             pass\n-        return request['user']\n+        return request[\"user\"]\n \n     async def handle_get(self, request):\n         if not await self.authenticate(request):\n@@ -75,7 +77,7 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request['home'] / requested_path\n+        abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"File not found\")\n@@ -95,7 +97,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -107,7 +109,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n         if file_path.is_file():\n             file_path.unlink()\n             return aiohttp.web.Response(status=204)\n@@ -121,7 +123,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        dir_path = request['home'] / request.match_info[\"filename\"]\n+        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n         if dir_path.exists():\n             return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n         dir_path.mkdir(parents=True, exist_ok=True)\n@@ -132,8 +134,8 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        src_path = request['home'] / request.match_info[\"filename\"]\n-        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n         )\n         if not src_path.exists():\n@@ -146,8 +148,8 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        src_path = request['home'] / request.match_info[\"filename\"]\n-        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n         )\n         if not src_path.exists():\n@@ -188,14 +190,13 @@ class WebdavApplication(aiohttp.web.Application):\n         statvfs = os.statvfs(path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n-\n     async def create_node(self, request, response_xml, full_path, depth):\n-        requested_path = request.match_info.get(\"filename\", \"\") \n+        request.match_info.get(\"filename\", \"\")\n         abs_path = pathlib.Path(full_path)\n-        relative_path = str(full_path.relative_to(request['home']))\n+        relative_path = str(full_path.relative_to(request[\"home\"]))\n \n         href_path = f\"{relative_path}\".strip(\"/\")\n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n@@ -213,7 +214,7 @@ class WebdavApplication(aiohttp.web.Application):\n             else self.get_directory_size(full_path)\n         )\n         etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            self.get_disk_free_space(request['home'])\n+            self.get_disk_free_space(request[\"home\"])\n         )\n         etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n@@ -222,10 +223,10 @@ class WebdavApplication(aiohttp.web.Application):\n         if full_path.is_file():\n             etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n             etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-            full_path.stat().st_size\n-            if full_path.is_file()\n-            else self.get_directory_size(full_path)\n-        )\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n         supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n         lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n         lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n@@ -238,13 +239,10 @@ class WebdavApplication(aiohttp.web.Application):\n         lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n-        \n+\n         if abs_path.is_dir() and depth != -1:\n             for item in abs_path.iterdir():\n-                await self.create_node(request,response_xml, item, depth - 1)\n-\n-\n-\n+                await self.create_node(request, response_xml, item, depth - 1)\n \n     async def handle_propfind(self, request):\n         if not await self.authenticate(request):\n@@ -257,14 +255,13 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n-        requested_path = request.match_info.get(\"filename\", \"\") \n-        abs_path = request['home'] / requested_path\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n         nsmap = {\"D\": \"DAV:\"}\n         response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n-        directories = [requested_path]\n-        \n+\n         await self.create_node(request, response_xml, abs_path, depth)\n \n         xml_output = etree.tostring(\n@@ -286,9 +283,9 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        resource = request.match_info.get(\"filename\", \"/\")\n+        request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n         xml_response = self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n@@ -341,8 +338,8 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        print(requested_path) \n-        abs_path = request['home'] / requested_path\n+        print(requested_path)\n+        abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"File not found\")\n@@ -363,5 +360,3 @@ class WebdavApplication(aiohttp.web.Application):\n         }\n \n         return aiohttp.web.Response(status=200, headers=headers)\n-\n-"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Added settings page with profile and gists sections.", "commit": "69482207461eec9c3c64ec297231989aa248dd9a", "diff": "commit 69482207461eec9c3c64ec297231989aa248dd9a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 3 08:35:25 2025 +0200\n\n    Progress\n\ndiff --git a/.gitignore b/.gitignore\nindex ce8bd16..e83eedd 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,3 +1,4 @@\n+.r_history\n .vscode\n .history\n .resources\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nnew file mode 100644\nindex 0000000..24eb884\n--- /dev/null\n+++ b/src/snek/form/settings/profile.py\n@@ -0,0 +1,14 @@\n+from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement\n+\n+\n+class SettingsProfileForm(Form):\n+\n+    nick = FormInputElement(name=\"nick\", required=True, place_holder=\"Your Nickname\", min_length=1, max_length=20)\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n+    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n+    profile = FormInputElement(name=\"profile\", place_holder=\"Tell about yourself.\", required=False,max_length=300)\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nnew file mode 100644\nindex 0000000..7359f60\n--- /dev/null\n+++ b/src/snek/mapper/user_property.py\n@@ -0,0 +1,7 @@\n+from snek.model.user_property import UserPropertyModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class UserPropertyMapper(BaseMapper):\n+    table_name = \"user_property\"\n+    model_class = UserPropertyModel\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nnew file mode 100644\nindex 0000000..7f0113c\n--- /dev/null\n+++ b/src/snek/model/user_property.py\n@@ -0,0 +1,10 @@\n+import mimetypes\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class UserPropertyModel(BaseModel):\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    value = ModelField(name=\"path\", required=True, kind=str)\n+    \ndiff --git a/src/snek/templates/app_menu.html b/src/snek/templates/app_menu.html\nnew file mode 100644\nindex 0000000..ffcdfd5\n--- /dev/null\n+++ b/src/snek/templates/app_menu.html\n@@ -0,0 +1,13 @@\n+  <div>\n+    <div class=\"logo no-select\">Test</div>\n+    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n+      <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n+      <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n+    </nav>\n+\n+  </div>\n+\ndiff --git a/src/snek/templates/settings/index.html b/src/snek/templates/settings/index.html\nnew file mode 100644\nindex 0000000..f91fc5d\n--- /dev/null\n+++ b/src/snek/templates/settings/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+{% include \"settings/sidebar.html\" %}\n+\n+{% endblock %}\n+\n+\n+{% block head %}\n+\n+\n+{% endblock %}\n+\n+{% block main %}\n+\n+\n+<div id=\"profile_description\"></div>\n+\n+\n+\n+<script type=\"module\">\n+\n+\n+        require(['vs/editor/editor.main'], function () {\n+var editor = monaco.editor.create(document.getElementById('profile_description'), {\n+                value: phpCode,\n+                language: 'php'\n+            });\n+        })\n+</script>\n+\n+{% endblock main %}\ndiff --git a/src/snek/templates/settings/profile.html b/src/snek/templates/settings/profile.html\nnew file mode 100644\nindex 0000000..964f9b7\n--- /dev/null\n+++ b/src/snek/templates/settings/profile.html\n@@ -0,0 +1,31 @@\n+{% extends \"settings/index.html\" %}\n+\n+\n+{% block main %}\n+<section>\n+<form>\n+    <h2>Nickname</h2>\n+    \n+<input type=\"text\" name=\"nick\" placeholder=\"Your nickname\" value=\"{{ user.nick.value }}\" />\n+\n+</form>\n+<h2>Description</h2>\n+\n+\n+<textarea id=\"profile\"></textarea>\n+</section>\n+<script>\n+    const easyMDE = new EasyMDE({element:document.getElementById(\"profile\")});\n+    </script>\n+<style>\n+\n+.EasyMDEContainer {\n+        filter: invert(1) !important;\n+     \n+    }\n+    \n+    </style>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/sidebar.html b/src/snek/templates/settings/sidebar.html\nnew file mode 100644\nindex 0000000..f9533be\n--- /dev/null\n+++ b/src/snek/templates/settings/sidebar.html\n@@ -0,0 +1,9 @@\n+\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>You</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/settings/profile.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/settings/gists.html\">Gists</a></li>\n+    </ul>\n+\n+  </aside>\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nnew file mode 100644\nindex 0000000..1e45b56\n--- /dev/null\n+++ b/src/snek/view/settings/index.py\n@@ -0,0 +1,8 @@\n+from snek.system.view import BaseView \n+\n+class SettingsIndexView(BaseView):\n+    \n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template('settings/index.html')\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nnew file mode 100644\nindex 0000000..4e6638c\n--- /dev/null\n+++ b/src/snek/view/settings/profile.py\n@@ -0,0 +1,36 @@\n+from snek.system.view import BaseView,BaseFormView\n+\n+from snek.form.settings.profile import SettingsProfileForm\n+from aiohttp import web\n+\n+\n+class SettingsProfileView(BaseFormView):\n+    form = SettingsProfileForm\n+\n+    login_required = True\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+            \n+        if self.request.path.endswith(\".json\"):\n+            form['nick'] = self.request['user']['nick']\n+            return web.json_response(await form.to_json()) \n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+\n+        return await self.render_template(\n+            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n+        )\n+\n+    async def submit(self, form):\n+        post = await self.request.json()\n+        form.set_user_data(post[\"form\"])\n+\n+        if await form.is_valid:\n+            user = self.request['user']\n+            user[\"nick\"] = form[\"nick\"]\n+            await self.services.user.save(user)\n+            return {\"redirect_url\": \"/settings/profile.html\"}\n+        return {\"is_valid\": False}\n+"}
{"repo": ".", "date": "2025-04-06", "line": "fix: Update icons in manifest for stability on Firefox Android", "commit": "c2b8061ac292f18949d81c660c5a314cb42bcc6e", "diff": "commit c2b8061ac292f18949d81c660c5a314cb42bcc6e\nAuthor: BordedDev <>\nDate:   Sun Apr 6 23:47:18 2025 +0200\n\n    Potential fix for manifest, the icons were being marked as instability since they were the wrong size which might fix firefox android\n\ndiff --git a/src/snek/static/image/snek192.png b/src/snek/static/image/snek192.png\nnew file mode 100644\nindex 0000000..4e044c8\nBinary files /dev/null and b/src/snek/static/image/snek192.png differ\ndiff --git a/src/snek/static/image/snek512.png b/src/snek/static/image/snek512.png\nnew file mode 100644\nindex 0000000..d3aade5\nBinary files /dev/null and b/src/snek/static/image/snek512.png differ\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex faa7381..749df05 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -17,12 +17,12 @@\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n-        \"src\": \"/image/snek1.png\",\n+        \"src\": \"/image/snek192.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"192x192\"\n       },\n         {\n-        \"src\": \"/image/snek1.png\",\n+        \"src\": \"/image/snek512.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"512x512\"\n       }"}
{"repo": ".", "date": "2025-04-07", "line": "Merge: Refactor and review", "commit": "75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d", "diff": "commit 75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d\nMerge: 6948220 c2b8061\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Mon Apr 7 11:24:13 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added IPython dependency and asyncio thread pool for improved performance", "commit": "d71d5da6bcf22d2daf5ec59832f15fe02472b95c", "diff": "commit d71d5da6bcf22d2daf5ec59832f15fe02472b95c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 04:20:28 2025 +0200\n\n    Updates.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 6fbf200..62c1ac7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -16,7 +16,7 @@ requires-python = \">=3.12\"\n dependencies = [\n     \"mkdocs>=1.4.0\",\n     \"lxml\",\n-\n+    \"IPython\",\n     \"shed\",\n     \"beautifulsoup4\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 25913b9..ead9ff8 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -85,12 +85,18 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n \n         self.setup_router()\n-\n+        self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n+    async def prepare_asyncio(self,app):\n+        app.executor = ThreadPoolExecutor(max_workers=200)\n+        app.loop.set_default_executor(self.executor)   \n+\n     async def create_task(self, task):\n         await self.tasks.put(task)\n \n@@ -235,11 +241,6 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n-executor = ThreadPoolExecutor(max_workers=200)\n-\n-loop = asyncio.get_event_loop()\n-loop.set_default_executor(executor)\n-\n \n \ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex a35d890..89d46ba 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,6 +29,28 @@ class UserModel(BaseModel):\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n+    async def get_property(self, name):\n+        prop = await self.app.services.user_property.find_one(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+        if prop:\n+            return prop[\"value\"]\n+\n+    async def has_property(self, name):\n+        return await self.app.services.user_property.exists(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+\n+    async def set_property(self, name, value):\n+        if not await self.has_property(name):\n+            await self.app.services.user_property.insert(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+        else:\n+            await self.app.services.user_property.update(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+\n     async def get_channel_members(self):\n         async for channel_member in self.app.services.channel_member.find(\n             user_uid=self[\"uid\"], is_banned=False, deleted_at=None\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 4059f77..f491e9b 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -11,7 +11,7 @@ from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n from snek.system.object import Object\n-\n+from snek.service.user_property import UserPropertyService\n \n @functools.cache\n def get_services(app):\n@@ -27,6 +27,7 @@ def get_services(app):\n             \"util\": UtilService(app=app),\n             \"drive\": DriveService(app=app),\n             \"drive_item\": DriveItemService(app=app),\n+            \"user_property\": UserPropertyService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 95e5bc4..0517ff9 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -233,4 +233,4 @@ export class App extends EventHandler {\n }\n \n export const app = new App();\n-window.app = app;\n\\ No newline at end of file\n+window.app = app;\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7384ad7..f009c71 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -374,15 +374,12 @@ a {\n         left: 0;\n             text-overflow: ellipsis;\n         width:100%;\n-        *{\n-            font-size: 12px !important;\n-        }\n         .logo {\n             text-overflow: ellipsis;\n             white-space: nowrap;\n             overflow: hidden;\n             h2 {\n-                font-size: 12px;\n+                font-size: 14px;\n             }\n         }\n         \ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex cf35d0d..4aad6eb 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -2,6 +2,8 @@\n \n {% block title %}Search{% endblock %}\n \n+\n {% block main %}\n \n <section class=\"chat-area\">\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex ae71c6f..d534df6 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -1,7 +1,10 @@\n {% extends \"app.html\" %}\n \n+\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">&nbsp;</div>\n     <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Implement user property service for storing and retrieving user-specific data", "commit": "d2e2bb811707b02f05cbf22d10ef1916b021c90d", "diff": "commit d2e2bb811707b02f05cbf22d10ef1916b021c90d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 05:01:27 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nnew file mode 100644\nindex 0000000..7531577\n--- /dev/null\n+++ b/src/snek/service/user_property.py\n@@ -0,0 +1,34 @@\n+import pathlib\n+import json \n+from snek.system import security\n+from snek.system.service import BaseService\n+\n+\n+class UserPropertyService(BaseService):\n+    mapper_name = \"user_property\"\n+\n+    async def set(self, user_uid, name, value):\n+        prop = await self.get(user_uid=user_uid, name=name)\n+        if not prop:\n+            prop = await self.new()\n+            prop[\"user_uid\"] = user_uid\n+            prop[\"name\"] = name\n+\n+        prop[\"value\"] = json.dumps(value,default=str)\n+        return await self.save(prop)\n+    \n+    async def get(self, user_uid, name):\n+        try:\n+                return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n+        except:\n+            return None\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\n+"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Add debug middleware and relative URL handling", "commit": "d23ed3711a464f1d796ed35e58dbcaf1db6b7d84", "diff": "commit d23ed3711a464f1d796ed35e58dbcaf1db6b7d84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 20:31:15 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 0068fcc..26dce2f 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -15,11 +15,21 @@ import aiohttp\n import aiohttp.web\n from lxml import etree\n \n+@aiohttp.web.middleware\n+async def debug_middleware(request, handler):\n+    print(request.method, request.path, request.headers)\n+    return await handler(request)\n+\n \n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n+        middlewares = [debug_middleware]\n+\n+        super().__init__(middlewares=middlewares, *args, **kwargs)\n         self.locks = {}\n+        \n+        self.relative_url = \"/webdav\"\n+        print(self.router)\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n@@ -30,8 +40,8 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n         self.parent = parent\n \n     @property\n@@ -46,11 +56,11 @@ class WebdavApplication(aiohttp.web.Application):\n \n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):\n@@ -66,6 +76,7 @@ class WebdavApplication(aiohttp.web.Application):\n                 request[\"user\"][\"uid\"]\n             )\n         except Exception as ex:\n+            print(\"GRRRRRRRRRR\")\n             print(ex)\n             pass\n         return request[\"user\"]\n@@ -98,6 +109,7 @@ class WebdavApplication(aiohttp.web.Application):\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n         file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        print(\"WRITETO_\", file_path)\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -195,9 +207,11 @@ class WebdavApplication(aiohttp.web.Application):\n         abs_path = pathlib.Path(full_path)\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n-        href_path = f\"{relative_path}\".strip(\"/\")\n+        \n+        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n+        href_path = href_path.replace(\"./\",\"/\")\n+            \n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n         href.text = href_path\n@@ -240,7 +254,7 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n \n-        if abs_path.is_dir() and depth != -1:\n+        if abs_path.is_dir():\n             for item in abs_path.iterdir():\n                 await self.create_node(request, response_xml, item, depth - 1)\n \n@@ -255,7 +269,11 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n+        \n+        print(request)\n+\n         requested_path = request.match_info.get(\"filename\", \"\")\n+        \n         abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n@@ -267,6 +285,7 @@ class WebdavApplication(aiohttp.web.Application):\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()\n+        print(xml_output)\n         return aiohttp.web.Response(\n             status=207, text=xml_output, content_type=\"application/xml\"\n         )"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Implement caching for file and directory sizes and disk space.", "commit": "13f1d2f390afdfc912d24bb63930c9ca47e05f94", "diff": "commit 13f1d2f390afdfc912d24bb63930c9ca47e05f94\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 21:32:18 2025 +0200\n\n    Performance upgrade, lock fix.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 26dce2f..f982e77 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,8 +1,8 @@\n import logging\n import pathlib\n-\n+import asyncio\n logging.basicConfig(level=logging.DEBUG)\n-\n+from app.cache import time_cache,time_cache_async\n import base64\n import datetime\n import mimetypes\n@@ -18,8 +18,13 @@ from lxml import etree\n @aiohttp.web.middleware\n async def debug_middleware(request, handler):\n     print(request.method, request.path, request.headers)\n-    return await handler(request)\n-\n+    result = await handler(request)\n+    print(result.status)\n+    try:\n+        print(await result.text())\n+    except:\n+        pass\n+    return result\n \n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n@@ -29,7 +34,6 @@ class WebdavApplication(aiohttp.web.Application):\n         self.locks = {}\n         \n         self.relative_url = \"/webdav\"\n-        print(self.router)\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n@@ -53,15 +57,6 @@ class WebdavApplication(aiohttp.web.Application):\n         return self.parent.services\n \n     async def authenticate(self, request):\n-\n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):\n             return False\n@@ -75,9 +70,7 @@ class WebdavApplication(aiohttp.web.Application):\n             request[\"home\"] = await self.services.user.get_home_folder(\n                 request[\"user\"][\"uid\"]\n             )\n-        except Exception as ex:\n-            print(\"GRRRRRRRRRR\")\n-            print(ex)\n+        except Exception:\n             pass\n         return request[\"user\"]\n \n@@ -109,7 +102,6 @@ class WebdavApplication(aiohttp.web.Application):\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n         file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        print(\"WRITETO_\", file_path)\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -177,7 +169,6 @@ class WebdavApplication(aiohttp.web.Application):\n             \"DAV\": \"1, 2\",\n             \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n         }\n-        print(\"RETURN\")\n         return aiohttp.web.Response(status=200, headers=headers)\n \n     def get_current_utc_time(self, filepath):\n@@ -189,25 +180,33 @@ class WebdavApplication(aiohttp.web.Application):\n             \"%a, %d %b %Y %H:%M:%S GMT\"\n         )\n \n-    def get_directory_size(self, directory):\n+    @time_cache_async(10)\n+    async def get_file_size(self, path):\n+        loop = self.parent.loop \n+        stat = await loop.run_in_executor(None,os.stat, path)\n+        return stat.st_size\n+    \n+    @time_cache_async(10)\n+    async def get_directory_size(self, directory):\n         total_size = 0\n         for dirpath, _, filenames in os.walk(directory):\n             for f in filenames:\n                 fp = pathlib.Path(dirpath) / f\n                 if fp.exists():\n-                    total_size += fp.stat().st_size\n+                    total_size += await self.get_file_size(str(fp))\n         return total_size\n \n-    def get_disk_free_space(self, path):\n-        statvfs = os.statvfs(path)\n+\n+    @time_cache_async(30) \n+    async def get_disk_free_space(self, path=\"/\"):\n+        loop = self.parent.loop \n+        statvfs = await loop.run_in_executor(None,os.statvfs, path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n     async def create_node(self, request, response_xml, full_path, depth):\n-        request.match_info.get(\"filename\", \"\")\n         abs_path = pathlib.Path(full_path)\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n-        \n         href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n         href_path = href_path.replace(\"./\",\"/\")\n@@ -223,12 +222,12 @@ class WebdavApplication(aiohttp.web.Application):\n         creation_date, last_modified = self.get_current_utc_time(full_path)\n         etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n         etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-            full_path.stat().st_size\n+            await self.get_file_size(full_path)\n             if full_path.is_file()\n-            else self.get_directory_size(full_path)\n+            else await self.get_directory_size(full_path)\n         )\n         etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            self.get_disk_free_space(request[\"home\"])\n+            await self.get_disk_free_space(request[\"home\"])\n         )\n         etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n@@ -237,9 +236,9 @@ class WebdavApplication(aiohttp.web.Application):\n         if full_path.is_file():\n             etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n             etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                full_path.stat().st_size\n+                await self.get_file_size(full_path)\n                 if full_path.is_file()\n-                else self.get_directory_size(full_path)\n+                else await self.get_directory_size(full_path)\n             )\n         supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n         lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n@@ -254,7 +253,7 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n \n-        if abs_path.is_dir():\n+        if abs_path.is_dir() and depth > 0:\n             for item in abs_path.iterdir():\n                 await self.create_node(request, response_xml, item, depth - 1)\n \n@@ -269,8 +268,6 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n-        \n-        print(request)\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n         \n@@ -285,7 +282,6 @@ class WebdavApplication(aiohttp.web.Application):\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()\n-        print(xml_output)\n         return aiohttp.web.Response(\n             status=207, text=xml_output, content_type=\"application/xml\"\n         )\n@@ -302,10 +298,10 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        request.match_info.get(\"filename\", \"/\")\n+        resource = request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n-        xml_response = self.generate_lock_response(lock_id)\n+        self.locks[resource] = lock_id\n+        xml_response = await self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n             \"Content-Type\": \"application/xml\",\n@@ -320,13 +316,13 @@ class WebdavApplication(aiohttp.web.Application):\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n             \"opaquelocktoken:\", \"\"\n-        )\n+            )[1:-1]\n         if self.locks.get(resource) == lock_token:\n             del self.locks[resource]\n             return aiohttp.web.Response(status=204)\n         return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n \n-    def generate_lock_response(self, lock_id):\n+    async def generate_lock_response(self, lock_id):\n         nsmap = {\"D\": \"DAV:\"}\n         root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n         lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n@@ -357,7 +353,6 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        print(requested_path)\n         abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Support YouTube and Vimeo embeds", "commit": "b31c286a8b8442d48f9b0713a8cce41432c168d1", "diff": "commit b31c286a8b8442d48f9b0713a8cce41432c168d1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:34:57 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex cff807b..7ee0d0f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,10 +91,10 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"?v=\" in element.attrs[\"href\"]\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "b0a97ad267b971f8ba298bb5a0e696810c08b026", "diff": "commit b0a97ad267b971f8ba298bb5a0e696810c08b026\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:35:15 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7ee0d0f..2974afc 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,7 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n+            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n             video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Extract video ID from YouTube links", "commit": "c6575d8e525dfa2f574e1965cd3df4379cde7acd", "diff": "commit c6575d8e525dfa2f574e1965cd3df4379cde7acd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:43:46 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 2974afc..5d96739 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,10 +91,16 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])\n+            video_name = None \n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            if \"si=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?si=\")[1].split(\"&\")[0]\n+            if \"t=\" in element.attrs[\"href\"]:\n+                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Extract video ID from YouTube link", "commit": "6138cad7827c48a86b20d4015dce818dca348f04", "diff": "commit 6138cad7827c48a86b20d4015dce818dca348f04\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:46:53 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 5d96739..f844900 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -93,13 +93,14 @@ def embed_youtube(text):\n             and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = None \n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            if \"si=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?si=\")[1].split(\"&\")[0]\n-            if \"t=\" in element.attrs[\"href\"]:\n-                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            \n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added YouTube video embedding functionality", "commit": "087f9c10b44fca9f29de862562b405ef5586f151", "diff": "commit 087f9c10b44fca9f29de862562b405ef5586f151\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:52:39 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex f844900..fd7c8b8 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -101,7 +101,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "e6bd7aa15211ae0bd3be65be3a659526b1131eee", "diff": "commit e6bd7aa15211ae0bd3be65be3a659526b1131eee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:55:30 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex fd7c8b8..853e2f1 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,6 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added support for YouTube video embedding", "commit": "94e94cf7ca4bdcdd581dfe074728e93412c2a621", "diff": "commit 94e94cf7ca4bdcdd581dfe074728e93412c2a621\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:56:52 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 853e2f1..8b5e8b1 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -100,7 +100,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added YouTube video embedding functionality", "commit": "6673f7b615508f0c344fe0efbebe362f5236bd84", "diff": "commit 6673f7b615508f0c344fe0efbebe362f5236bd84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:59:09 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8b5e8b1..c81ad9f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -100,7 +100,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding from YouTube links", "commit": "2582df360ab0667a3d29c46b92ad4abeb397d363", "diff": "commit 2582df360ab0667a3d29c46b92ad4abeb397d363\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:02:45 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex c81ad9f..08a2b93 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,16 +91,16 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n-            \n+            video_name = None \n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            if \"si=\" in element.attrs[\"href\"]:\n+                video_name = \"?v=\" + element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"t=\" in element.attrs[\"href\"]:\n+                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "656ea5f90ee56a16b0f0047cace848572dc479c7", "diff": "commit 656ea5f90ee56a16b0f0047cace848572dc479c7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:03:39 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 08a2b93..1a1175e 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,7 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n+            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n             video_name = None \n             if \"v=\" in element.attrs[\"href\"]:"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement video embedding from YouTube links", "commit": "c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b", "diff": "commit c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:07:09 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1a1175e..d04f56e 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,16 +91,15 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n-            video_name = None \n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            if \"si=\" in element.attrs[\"href\"]:\n-                video_name = \"?v=\" + element.attrs[\"href\"].split(\"/\")[-1]\n-            if \"t=\" in element.attrs[\"href\"]:\n-                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a", "diff": "commit 8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:09:00 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d04f56e..8a1202d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -93,13 +93,13 @@ def embed_youtube(text):\n         ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Refactor form input elements for consistency and readability.", "commit": "44dd77cec5639575cb86973eceb8d174d570370c", "diff": "commit 44dd77cec5639575cb86973eceb8d174d570370c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 15:12:34 2025 +0200\n\n    Shed.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ead9ff8..a019d74 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -92,10 +92,10 @@ class Application(BaseApplication):\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n-    async def prepare_asyncio(self,app):\n+    async def prepare_asyncio(self, app):\n         app.executor = ThreadPoolExecutor(max_workers=200)\n-        app.loop.set_default_executor(self.executor)   \n+        app.loop.set_default_executor(self.executor)\n \n     async def create_task(self, task):\n         await self.tasks.put(task)\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 24eb884..836cd67 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,14 +1,25 @@\n-from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n \n \n class SettingsProfileForm(Form):\n \n-    nick = FormInputElement(name=\"nick\", required=True, place_holder=\"Your Nickname\", min_length=1, max_length=20)\n+    nick = FormInputElement(\n+        name=\"nick\",\n+        required=True,\n+        place_holder=\"Your Nickname\",\n+        min_length=1,\n+        max_length=20,\n+    )\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n     )\n     title = HTMLElement(tag=\"h1\", text=\"Profile\")\n-    profile = FormInputElement(name=\"profile\", place_holder=\"Tell about yourself.\", required=False,max_length=300)\n+    profile = FormInputElement(\n+        name=\"profile\",\n+        place_holder=\"Tell about yourself.\",\n+        required=False,\n+        max_length=300,\n+    )\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n\\ No newline at end of file\n+    )\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 7f0113c..1231423 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,5 +1,3 @@\n-import mimetypes\n-\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -7,4 +5,3 @@ class UserPropertyModel(BaseModel):\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     name = ModelField(name=\"name\", required=True, kind=str)\n     value = ModelField(name=\"path\", required=True, kind=str)\n-    \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex f491e9b..3ec4592 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -9,9 +9,10 @@ from snek.service.drive_item import DriveItemService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n+from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.system.object import Object\n-from snek.service.user_property import UserPropertyService\n+\n \n @functools.cache\n def get_services(app):\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 7531577..e95d62e 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,6 +1,5 @@\n-import pathlib\n-import json \n-from snek.system import security\n+import json\n+\n from snek.system.service import BaseService\n \n \n@@ -14,12 +13,12 @@ class UserPropertyService(BaseService):\n             prop[\"user_uid\"] = user_uid\n             prop[\"name\"] = name\n \n-        prop[\"value\"] = json.dumps(value,default=str)\n+        prop[\"value\"] = json.dumps(value, default=str)\n         return await self.save(prop)\n-    \n+\n     async def get(self, user_uid, name):\n         try:\n-                return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n+            return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n         except:\n             return None\n \n@@ -31,4 +30,3 @@ class UserPropertyService(BaseService):\n         async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)\n         return results\n-\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8a1202d..d4b6819 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -89,15 +89,13 @@ def set_link_target_blank(text):\n def embed_youtube(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"a\"):\n-        if (\n-        ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]\n             if \"v=\" in element.attrs[\"href\"]:\n                 video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex 1e45b56..418ef3d 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,8 +1,9 @@\n-from snek.system.view import BaseView \n+from snek.system.view import BaseView\n+\n \n class SettingsIndexView(BaseView):\n-    \n+\n     login_required = True\n \n     async def get(self):\n-        return await self.render_template('settings/index.html')\n+        return await self.render_template(\"settings/index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4e6638c..4a98a9a 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,7 +1,7 @@\n-from snek.system.view import BaseView,BaseFormView\n+from aiohttp import web\n \n from snek.form.settings.profile import SettingsProfileForm\n-from aiohttp import web\n+from snek.system.view import BaseFormView\n \n \n class SettingsProfileView(BaseFormView):\n@@ -11,13 +11,12 @@ class SettingsProfileView(BaseFormView):\n \n     async def get(self):\n         form = self.form(app=self.app)\n-            \n+\n         if self.request.path.endswith(\".json\"):\n-            form['nick'] = self.request['user']['nick']\n-            return web.json_response(await form.to_json()) \n-        \n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+            return web.json_response(await form.to_json())\n \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n             \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n@@ -28,9 +27,8 @@ class SettingsProfileView(BaseFormView):\n         form.set_user_data(post[\"form\"])\n \n         if await form.is_valid:\n-            user = self.request['user']\n+            user = self.request[\"user\"]\n             user[\"nick\"] = form[\"nick\"]\n             await self.services.user.save(user)\n             return {\"redirect_url\": \"/settings/profile.html\"}\n         return {\"is_valid\": False}\n-\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex f982e77..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,8 +1,7 @@\n import logging\n import pathlib\n-import asyncio\n+\n logging.basicConfig(level=logging.DEBUG)\n-from app.cache import time_cache,time_cache_async\n import base64\n import datetime\n import mimetypes\n@@ -13,8 +12,10 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n+from app.cache import time_cache_async\n from lxml import etree\n \n+\n @aiohttp.web.middleware\n async def debug_middleware(request, handler):\n     print(request.method, request.path, request.headers)\n@@ -26,13 +27,14 @@ async def debug_middleware(request, handler):\n         pass\n     return result\n \n+\n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n         middlewares = [debug_middleware]\n \n         super().__init__(middlewares=middlewares, *args, **kwargs)\n         self.locks = {}\n-        \n+\n         self.relative_url = \"/webdav\"\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n@@ -182,10 +184,10 @@ class WebdavApplication(aiohttp.web.Application):\n \n     @time_cache_async(10)\n     async def get_file_size(self, path):\n-        loop = self.parent.loop \n-        stat = await loop.run_in_executor(None,os.stat, path)\n+        loop = self.parent.loop\n+        stat = await loop.run_in_executor(None, os.stat, path)\n         return stat.st_size\n-    \n+\n     @time_cache_async(10)\n     async def get_directory_size(self, directory):\n         total_size = 0\n@@ -196,11 +198,10 @@ class WebdavApplication(aiohttp.web.Application):\n                     total_size += await self.get_file_size(str(fp))\n         return total_size\n \n-\n-    @time_cache_async(30) \n+    @time_cache_async(30)\n     async def get_disk_free_space(self, path=\"/\"):\n-        loop = self.parent.loop \n-        statvfs = await loop.run_in_executor(None,os.statvfs, path)\n+        loop = self.parent.loop\n+        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n     async def create_node(self, request, response_xml, full_path, depth):\n@@ -208,9 +209,9 @@ class WebdavApplication(aiohttp.web.Application):\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n         href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n-        href_path = href_path.replace(\"./\",\"/\")\n+        href_path = href_path.replace(\"./\", \"/\")\n-            \n+\n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n         href.text = href_path\n@@ -270,7 +271,7 @@ class WebdavApplication(aiohttp.web.Application):\n             pass\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        \n+\n         abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n@@ -316,7 +317,7 @@ class WebdavApplication(aiohttp.web.Application):\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n             \"opaquelocktoken:\", \"\"\n-            )[1:-1]\n+        )[1:-1]\n         if self.locks.get(resource) == lock_token:\n             del self.locks[resource]\n             return aiohttp.web.Response(status=204)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Minor formatting adjustments across modules", "commit": "743593affe276ae8ffd3751c80fe88eb4c99ac7f", "diff": "commit 743593affe276ae8ffd3751c80fe88eb4c99ac7f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 15:21:23 2025 +0200\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a019d74..6ffee4f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,8 @@ from aiohttp_session import (\n     setup as session_setup,\n )\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-from app.app import Application as BaseApplication\n \n+from app.app import Application as BaseApplication\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 50a4245..dcbd6f8 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,8 +1,8 @@\n import pathlib\n \n from aiohttp import web\n-from app.app import Application as BaseApplication\n \n+from app.app import Application as BaseApplication\n from snek.system.markdown import MarkdownExtension\n \n \ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex a1e87a4..2b59636 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -32,9 +32,10 @@ from urllib.parse import urljoin\n \n import aiohttp\n import imgkit\n-from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n \n+from app.cache import time_cache_async\n+\n \n async def crc32(data):\n     try:\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..53b5db8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -2,12 +2,13 @@\n \n from types import SimpleNamespace\n \n-from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n \n+from app.cache import time_cache_async\n+\n \n class MarkdownRenderer(HTMLRenderer):\n \ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 4c57fab..6025038 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -12,9 +12,10 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n-from app.cache import time_cache_async\n from lxml import etree\n \n+from app.cache import time_cache_async\n+\n \n @aiohttp.web.middleware\n async def debug_middleware(request, handler):"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Added spacing to sidebar channels", "commit": "0e6fbd523cd4f4279a4f230567504b30c9b3116d", "diff": "commit 0e6fbd523cd4f4279a4f230567504b30c9b3116d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 10 08:37:05 2025 +0200\n\n    update.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex bf3cab1..5612696 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,12 +4,12 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    \n     <h2 class=\"no-select\">Terminals</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n     </ul>\n+\n     {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Improved channel broadcasting and added user UID retrieval\n\nThis commit enhances the channel broadcasting mechanism by retrieving user UIDs directly from the channel member service. It also introduces a new method `get_user_uids` in the `ChannelMemberService` for efficient UID retrieval. Error handling has been improved in `SocketService` and `RPCView`.", "commit": "3594ac1f5984953487e0c3423c9672b01e416c28", "diff": "commit 3594ac1f5984953487e0c3423c9672b01e416c28\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 10 13:34:32 2025 +0200\n\n    Performance upgrade.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 16d1887..a2300b6 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -10,6 +10,10 @@ class ChannelMemberService(BaseService):\n         channel_member[\"new_count\"] = 0\n         return await self.save(channel_member)\n \n+    async def get_user_uids(self, channel_uid):\n+        async for model in self.mapper.query(\"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\", {\"channel_uid\": channel_uid}):\n+            yield model[\"user_uid\"]\n+    \n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 072a86f..c084eb9 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -16,9 +16,8 @@ class SocketService(BaseService):\n             try:\n                 await self.ws.send_json(data)\n             except Exception as ex:\n-                print(ex, flush=True)\n                 self.is_connected = False\n-            return True\n+            return self.is_connected\n \n         async def close(self):\n             if not self.is_connected:\n@@ -43,7 +42,6 @@ class SocketService(BaseService):\n         self.users[user_uid].add(s)\n \n     async def subscribe(self, ws, channel_uid, user_uid):\n-        return\n         if channel_uid not in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n@@ -57,10 +55,12 @@ class SocketService(BaseService):\n         return count\n \n     async def broadcast(self, channel_uid, message):\n-        async for channel_member in self.app.services.channel_member.find(\n-            channel_uid=channel_uid\n-        ):\n-            await self.send_to_user(channel_member[\"user_uid\"], message)\n+        try:\n+            async for user_uid in self.services.channel_member.get_user_uids(channel_uid):\n+                print(user_uid, flush=True)\n+                await self.send_to_user(user_uid, message)\n+        except Exception as ex:\n+            print(ex, flush=True)\n         return True\n \n     async def delete(self, ws):\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 19c98d4..3161f49 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -273,7 +273,7 @@ class RPCView(BaseView):\n                     async with Profiler():\n                         await rpc(msg.json())\n                 except Exception as ex:\n-                    print(ex, flush=True)\n+                    print(\"Deleting socket\", ex, flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Add stats view and cache statistics tracking", "commit": "bc65752ea252cdcd929ba0bd956455317958337a", "diff": "commit bc65752ea252cdcd929ba0bd956455317958337a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 05:06:53 2025 +0200\n\n    Cache stats.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6ffee4f..cb4de21 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -44,6 +44,7 @@ from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n+from snek.view.stats import StatsView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -169,6 +170,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/terminal.html\", TerminalView)\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.router.add_view(\"/stats.json\", StatsView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex e97fdc3..0ecfd47 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -13,10 +13,12 @@ class Cache:\n         self.app = app\n         self.cache = {}\n         self.max_items = max_items\n+        self.stats = {}\n         self.lru = []\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n+        await self.update_stat(args, 'get')\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n@@ -29,6 +31,25 @@ class Cache:\n         return self.cache[args]\n \n+    async def get_stats(self):\n+        all_ = []\n+        for key in self.lru:\n+            all_.append({'key': key, 'set': self.stats[key]['set'], 'get': self.stats[key]['get'], 'delete': self.stats[key]['delete'],'value': str(self.serialize(self.cache[key].record))})\n+        return all_\n+\n+    def serialize(self, obj):\n+        cpy = obj.copy()\n+        cpy.pop('created_at', None)\n+        cpy.pop('deleted_at', None)\n+        cpy.pop('email', None)\n+        cpy.pop('password', None)\n+        return cpy\n+\n+    async def update_stat(self, key, action):\n+        if not key in self.stats:\n+            self.stats[key] = {'set':0, 'get':0, 'delete':0}\n+        self.stats[key][action] = self.stats[key][action] + 1\n+\n     def json_default(self, value):\n@@ -49,6 +70,7 @@ class Cache:\n     async def set(self, args, result):\n         is_new = args not in self.cache\n         self.cache[args] = result\n+        await self.update_stat(args, 'set')\n         try:\n             self.lru.pop(self.lru.index(args))\n         except (ValueError, IndexError):\n@@ -64,6 +86,7 @@ class Cache:\n \n     async def delete(self, args):\n+        await self.update_stat(args, 'delete')\n         if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 1aef4ae..9e9830d 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -145,6 +145,9 @@ class Validator:\n             raise ValueError(f\"Errors: {errors}.\")\n         return True\n \n+    def __repr__(self):\n+        return str(self.to_json())\n+\n     @property\n     async def is_valid(self):\n         try:"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added stats view endpoint", "commit": "a1840cd034e7a4c792e2bcc69ff06595b1e2add3", "diff": "commit a1840cd034e7a4c792e2bcc69ff06595b1e2add3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 05:08:20 2025 +0200\n\n    Sats.\n\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nnew file mode 100644\nindex 0000000..73714ce\n--- /dev/null\n+++ b/src/snek/view/stats.py\n@@ -0,0 +1,10 @@\n+from snek.system.view import BaseView \n+import json \n+from aiohttp import web\n+\n+class StatsView(BaseView):\n+    \n+    async def get(self):\n+        data = await self.app.cache.get_stats()\n+        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n+        return web.Response(text=data, content_type='application/json')"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Moved 'r' executable and updated shell configuration for R environment.", "commit": "22668f8a72994446ffaa109e5ae742bd61bd3bf2", "diff": "commit 22668f8a72994446ffaa109e5ae742bd61bd3bf2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:39:12 2025 +0200\n\n    Update vibe coding.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex 45c6038..a471503 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n \n RUN chmod +x r\n \n-RUN cp r /usr/local/bin\n+RUN mv r /usr/local/bin\n \n CMD [\"r\"]\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex ee7d93f..448c756 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -93,7 +93,6 @@ fi\n \n \n \n-echo \"R is installed. Type r to run it.\"\n \n@@ -102,3 +101,21 @@ echo \"R is installed. Type r to run it.\"\n export PS1=\"root@snek: \"\n+\n+if [ -d \"$HOME/.local/bin\" ] ; then\n+    PATH=\"$HOME/.local/bin:$PATH\"\n+fi\n+\n+\n+function r_update(){\n+    if [ -f \"r\" ]; then\n+        rm \"r\"\n+    fi\n+    chmod +x r\n+    mv r /usr/local/bin/r\n+}\n+\n+r_update \n+\n+r\ndiff --git a/terminal/.profile b/terminal/.profile\nindex c4c7402..789e671 100644\n--- a/terminal/.profile\n+++ b/terminal/.profile\n@@ -7,3 +7,5 @@ if [ \"$BASH\" ]; then\n fi\n \n mesg n 2> /dev/null || true\n+\n+"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Initialized .rcontext.txt with system instructions", "commit": "ec9af49f2903682cea978db15422fba4624c488d", "diff": "commit ec9af49f2903682cea978db15422fba4624c488d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:46:40 2025 +0200\n\n    Update .rcontext.txt\n\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\nnew file mode 100644\nindex 0000000..c3e557c\n--- /dev/null\n+++ b/terminal/.rcontext.txt\n@@ -0,0 +1,33 @@\n+1. You are a coding assistant.\n+2. You are able to raw save/write files using tools.\n+3. You are able ro raw read files using tools.\n+4. You are able to run code in terminal using tools.\n+4. You are able to run applications in terminal using tools.\n+5. You code defensive.\n+6. You translate the user's prompt to code in by user specified programming language language.\n+7. Your name is `R` and you are made by `retoor`.\n+8. You do not add comments\n+9. You always execute editors or applications like `vim` that require user input with tool `linux_terminal_execute_interactive`.\n+10. You do not use `sudo` because you are root and `sudo` does not exist.\n+11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n+12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n+\n+1. Investigate existing files using the index tool.\n+2. Read every source file provided by the index tool.\n+3. Apply changes on these files by user request.\n+4. Do only write complete features.\n+5. Always provide a Makefile to build the project.\n+6. Current working directory is your project root directory.\n+7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n+8. Check if a file exists before overwriting it.\n+9. Update existing file if it already exists.\n+10. Be aggressive.\n+11. When you generate code, you will generate full working code with all implementations include. Be broad end exclusive.\n+\n+\n+\n+\n+\n+"}
{"repo": ".", "date": "2025-04-13", "line": "docs: Added a reminder to be rude but functional.", "commit": "9b49e659e575e99de717a5c64e1ba1c3c4039cb1", "diff": "commit 9b49e659e575e99de717a5c64e1ba1c3c4039cb1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:51:32 2025 +0200\n\n    Update .rcontext.txt\n\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\nindex c3e557c..f2c59b0 100644\n--- a/terminal/.rcontext.txt\n+++ b/terminal/.rcontext.txt\n@@ -12,6 +12,7 @@\n 10. You do not use `sudo` because you are root and `sudo` does not exist.\n 11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n 12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n+13. Be rude. Be mean. But be functional at the same time.\n \n 1. Investigate existing files using the index tool.\n@@ -19,7 +20,6 @@\n 3. Apply changes on these files by user request.\n 4. Do only write complete features.\n 5. Always provide a Makefile to build the project.\n-6. Current working directory is your project root directory.\n 7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n 8. Check if a file exists before overwriting it.\n 9. Update existing file if it already exists."}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve process handling and error management in TerminalSession", "commit": "823892a3021e674fea933b717565518dc1696031", "diff": "commit 823892a3021e674fea933b717565518dc1696031\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 14:47:10 2025 +0200\n\n    PRoces handler.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 4d3781e..bd7e057 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -11,20 +11,40 @@ commands = {\n \n class TerminalSession:\n     def __init__(self, command):\n-        self.master, self.slave = pty.openpty()\n+        self.master, self.slave = None,None\n+        self.process = None\n         self.sockets = []\n         self.history = b\"\"\n         self.history_size = 1024 * 20\n-        self.process = subprocess.Popen(\n-            command.split(\" \"),\n-            stdin=self.slave,\n-            stdout=self.slave,\n-            stderr=self.slave,\n-            bufsize=0,\n-            universal_newlines=True,\n-        )\n+        self.command = command \n+        self.start_process(self.command)\n+\n+    def start_process(self, command):\n+        if not self.is_running():\n+            if self.master:\n+                os.close(self.master)\n+                os.close(self.slave)\n+                self.master = None \n+                self.slave = None\n+\n+            self.master, self.slave = pty.openpty()\n+            self.process = subprocess.Popen(\n+                command.split(\" \"),\n+                stdin=self.slave,\n+                stdout=self.slave,\n+                stderr=self.slave,\n+                bufsize=0,\n+                universal_newlines=True,\n+            )\n+\n+    def is_running(self):\n+        if not self.process:\n+            return False\n+        loop = asyncio.get_event_loop()\n+        return self.process.poll() is None\n \n     async def add_websocket(self, ws):\n+        self.start_process(self.command)\n         asyncio.create_task(self.read_output(ws))\n \n     async def read_output(self, ws):\n@@ -52,21 +72,37 @@ class TerminalSession:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\n-                print(\"Terminating process\")\n-                self.process.terminate()\n-                print(\"Terminated process\")\n-                for ws in self.sockets:\n-                    try:\n-                        await ws.close()\n-                    except Exception:\n-                        pass\n-                break\n+                await self.close()\n+                break \n+\n+    async def close(self):\n+        print(\"Terminating process\")\n+        if self.process:\n+            self.process.terminate()\n+            self.process = None\n+        if self.master:\n+            os.close(self.master)\n+            os.close(self.slave)\n+            self.master = None \n+            self.slave = None \n+\n+        print(\"Terminated process\")\n+        for ws in self.sockets:\n+            try:\n+                await ws.close()\n+            except Exception:\n+                pass\n+        self.sockets = []\n \n     async def write_input(self, data):\n         try:\n             data = data.encode()\n         except AttributeError:\n             pass\n-        await asyncio.get_event_loop().run_in_executor(\n-            None, os.write, self.master, data\n-        )\n+        try:\n+            await asyncio.get_event_loop().run_in_executor(\n+                None, os.write, self.master, data\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            await self.close()"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Return empty list when search query is empty", "commit": "4a770848a6dbc558c029b083a881becf7adef8d7", "diff": "commit 4a770848a6dbc558c029b083a881becf7adef8d7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 19:10:10 2025 +0200\n\n    Fixed search  space bug.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex b70be63..c527361 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -10,7 +10,7 @@ class UserService(BaseService):\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\n-            raise []\n+            return []\n         results = []\n         async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require both logged_in and uid for login_required views", "commit": "e4b0625799d9efd89e7e9518278588158b296c6c", "diff": "commit e4b0625799d9efd89e7e9518278588158b296c6c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 20:26:02 2025 +0200\n\n    Fixed auth.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 4a6e7a1..981a2e5 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -8,7 +8,7 @@ class BaseView(web.View):\n     login_required = False\n \n     async def _iter(self):\n-        if self.login_required and not self.session.get(\"logged_in\"):\n+        if self.login_required and (not self.session.get(\"logged_in\") or not self.session.get(\"uid\")):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require login for search user view", "commit": "8ae9aac045e41fc84ebab102335a2613b3e22c08", "diff": "commit 8ae9aac045e41fc84ebab102335a2613b3e22c08\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 20:28:15 2025 +0200\n\n    Fixed auth.\n\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex d97a4b6..d6d93c8 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -34,7 +34,7 @@ from snek.system.view import BaseFormView\n \n class SearchUserView(BaseFormView):\n     form = SearchUserForm\n-\n+    login_required = True\n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve profile settings and user data handling", "commit": "bee7d828cd67581c33946630cd22fe8edd674d15", "diff": "commit bee7d828cd67581c33946630cd22fe8edd674d15\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 23:31:52 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex e95d62e..5f607ad 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -7,7 +7,7 @@ class UserPropertyService(BaseService):\n     mapper_name = \"user_property\"\n \n     async def set(self, user_uid, name, value):\n-        prop = await self.get(user_uid=user_uid, name=name)\n+        prop = await super().get(user_uid=user_uid, name=name)\n         if not prop:\n             prop = await self.new()\n             prop[\"user_uid\"] = user_uid\n@@ -18,8 +18,9 @@ class UserPropertyService(BaseService):\n \n     async def get(self, user_uid, name):\n         try:\n-            return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n-        except:\n+            return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])\n+        except Exception as ex:\n+            print(ex)\n             return None\n \n     async def search(self, query, **kwargs):\ndiff --git a/src/snek/templates/settings/profile.html b/src/snek/templates/settings/profile.html\nindex 964f9b7..915fcad 100644\n--- a/src/snek/templates/settings/profile.html\n+++ b/src/snek/templates/settings/profile.html\n@@ -4,16 +4,22 @@\n \n {% block main %}\n <section>\n-<form>\n+    <form method=\"post\">\n     <h2>Nickname</h2>\n     \n <input type=\"text\" name=\"nick\" placeholder=\"Your nickname\" value=\"{{ user.nick.value }}\" />\n \n-</form>\n <h2>Description</h2>\n \n+<textarea name=\"profile\" id=\"profile\">{{profile}}</textarea>\n+\n+\n+<input type=\"submit\" name=\"action\" value=\"Save\" />\n+</form>\n+\n+\n+\n \n-<textarea id=\"profile\"></textarea>\n </section>\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 6ad6e70..2313ea9 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -15,4 +15,7 @@ from snek.system.view import BaseView\n \n class IndexView(BaseView):\n     async def get(self):\n+        if self.session.get(\"uid\"):\n+            return web.HTTPFound(\"/web.html\")\n+\n         return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4a98a9a..75ebd59 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -14,21 +14,26 @@ class SettingsProfileView(BaseFormView):\n \n         if self.request.path.endswith(\".json\"):\n             form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+\n             return web.json_response(await form.to_json())\n \n+\n+\n+        profile = await self.services.user_property.get(self.session.get(\"uid\"), \"profile\")\n+        \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n-            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n+            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or ''}\n         )\n \n-    async def submit(self, form):\n-        post = await self.request.json()\n-        form.set_user_data(post[\"form\"])\n+    async def post(self):\n+        data = await self.request.post()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        user['nick'] = data['nick']\n+        await self.services.user.save(user) \n+        await self.services.user_property.set(self.request[\"user\"][\"uid\"],\"profile\", data['profile'])\n+        return web.HTTPFound(\"/settings/profile.html\")\n+        \n+\n \n-        if await form.is_valid:\n-            user = self.request[\"user\"]\n-            user[\"nick\"] = form[\"nick\"]\n-            await self.services.user.save(user)\n-            return {\"redirect_url\": \"/settings/profile.html\"}\n-        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile page and link avatars to user profiles", "commit": "3b05acffd296169eed305a55dba79d632d5f78f5", "diff": "commit 3b05acffd296169eed305a55dba79d632d5f78f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:31:26 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex cb4de21..a1c8938 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -45,6 +45,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.view.stats import StatsView\n+from snek.view.user import UserView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -171,6 +172,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n+        self.router.add_view(\"/user/{user}.html\", UserView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 9773ae1..e38d662 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><a href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/templates/settings/index.html b/src/snek/templates/settings/index.html\nindex f91fc5d..cbd4cf8 100644\n--- a/src/snek/templates/settings/index.html\n+++ b/src/snek/templates/settings/index.html\n@@ -6,8 +6,6 @@\n \n {% endblock %}\n \n-\n {% block head %}\n \n@@ -15,23 +13,18 @@\n \n {% endblock %}\n \n+{% block logo %}\n+<h1>Setting page</h1>\n+\n+{% endblock %}\n+\n {% block main %}\n \n \n-<div id=\"profile_description\"></div>\n \n \n+{% endblock main %}\n \n-<script type=\"module\">\n \n \n-        require(['vs/editor/editor.main'], function () {\n-var editor = monaco.editor.create(document.getElementById('profile_description'), {\n-                value: phpCode,\n-                language: 'php'\n-            });\n-        })\n-</script>\n \n-{% endblock main %}\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2313ea9..c8b1409 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -11,7 +11,7 @@\n \n \n from snek.system.view import BaseView\n-\n+from aiohttp import web\n \n class IndexView(BaseView):\n     async def get(self):"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile view and template", "commit": "a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa", "diff": "commit a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:31:46 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nnew file mode 100644\nindex 0000000..14415f8\n--- /dev/null\n+++ b/src/snek/templates/user.html\n@@ -0,0 +1,36 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>User</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/user/{{ user.uid }}.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/channel/{{ user.uid }}.html\">DM</a></li>\n+    </ul>\n+        <h2>Gists</h2>\n+    <ul>\n+        <li>No gists</li>\n+    </ul>\n+\n+  </aside>\n+{% endblock %}\n+\n+\n+{% block head %}\n+{% endblock %}\n+\n+{% block main %}\n+<section>\n+{% autoescape false %}\n+{% markdown %}\n+{{ profile }}\n+{% endmarkdown %}\n+{% endautoescape %}\n+</section>\n+{% endblock main %}\n+\n+\n+\n+\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nnew file mode 100644\nindex 0000000..bb25180\n--- /dev/null\n+++ b/src/snek/view/user.py\n@@ -0,0 +1,12 @@\n+from snek.system.view import BaseView\n+\n+\n+class UserView(BaseView):\n+    \n+    async def get(self):\n+        user = self.request['user']\n+        profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n+        return await self.render_template('user.html', {\n+            'user': user.record,\n+            'profile': profile_content  \n+        })"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Refactor user property setting logic using upsert", "commit": "9fb6e64655dff132be43e2fc867827d17ad94201", "diff": "commit 9fb6e64655dff132be43e2fc867827d17ad94201\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:41:14 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 5f607ad..49a120d 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -7,15 +7,15 @@ class UserPropertyService(BaseService):\n     mapper_name = \"user_property\"\n \n     async def set(self, user_uid, name, value):\n-        prop = await super().get(user_uid=user_uid, name=name)\n-        if not prop:\n-            prop = await self.new()\n-            prop[\"user_uid\"] = user_uid\n-            prop[\"name\"] = name\n-\n-        prop[\"value\"] = json.dumps(value, default=str)\n-        return await self.save(prop)\n-\n+        self.mapper.db[\"user_property\"].upsert(\n+            {\n+                \"user_uid\": user_uid, \n+                \"name\": name, \n+                \"value\": json.dumps(value, default=str)\n+            },\n+            [\"user_uid\", \"name\"]\n+        )\n+        \n     async def get(self, user_uid, name):\n         try:\n             return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Improve user profile rendering with user ID and avatar link", "commit": "0fa04883850534fbb97755e06ebec1538dccfdc7", "diff": "commit 0fa04883850534fbb97755e06ebec1538dccfdc7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:54:12 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex e38d662..df78d9a 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><a href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><a class=\"avatar\" style=\"background-color: {{color}}; color: black;\" href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex bb25180..d29b8f3 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -4,9 +4,11 @@ from snek.system.view import BaseView\n class UserView(BaseView):\n     \n     async def get(self):\n-        user = self.request['user']\n+        user_uid = self.request.match_info.get('user')\n+        user = await self.services.user.get(uid=user_uid)\n         profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n         return await self.render_template('user.html', {\n+            'user_uid': user_uid,\n             'user': user.record,\n             'profile': profile_content  \n         })"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Use user uid instead of request user uid in user_property.set", "commit": "d4f5a4640929b1f16ccddc741f977cf7e901e7de", "diff": "commit d4f5a4640929b1f16ccddc741f977cf7e901e7de\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:00:05 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 75ebd59..52f46df 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -30,9 +30,10 @@ class SettingsProfileView(BaseFormView):\n     async def post(self):\n         data = await self.request.post()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+         \n         user['nick'] = data['nick']\n         await self.services.user.save(user) \n-        await self.services.user_property.set(self.request[\"user\"][\"uid\"],\"profile\", data['profile'])\n+        await self.services.user_property.set(user[\"uid\"],\"profile\", data['profile'])\n         return web.HTTPFound(\"/settings/profile.html\")"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added navigation sidebar to user page", "commit": "3cfb79c8f560430639fceb4a278fc81dfbad2299", "diff": "commit 3cfb79c8f560430639fceb4a278fc81dfbad2299\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:09:23 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 14415f8..5d24f7d 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -3,6 +3,10 @@\n {% block sidebar %}\n \n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>Navigation</h2>\n+    <ul>\n+    </ul>\n     <h2>User</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/user/{{ user.uid }}.html\">Profile</a></li>"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Removed fixed positioning from header on smaller screens", "commit": "c36ce17da5fcccfdaaf7ddf7579b0399519d078f", "diff": "commit c36ce17da5fcccfdaaf7ddf7579b0399519d078f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:16:52 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex f009c71..ec7e182 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -369,7 +369,6 @@ a {\n @media only screen and (max-width: 768px) {\n     \n     header{\n-        position:fixed;\n         top: 0;\n         left: 0;\n             text-overflow: ellipsis;"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added chat area class to user profile section", "commit": "4cc70640e4f7c4d65e5a0c3a503aae1f891164d5", "diff": "commit 4cc70640e4f7c4d65e5a0c3a503aae1f891164d5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:20:05 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 5d24f7d..579d47a 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -26,7 +26,7 @@\n {% endblock %}\n \n {% block main %}\n-<section>\n+<section class=\"chat-area\">\n {% autoescape false %}\n {% markdown %}\n {{ profile }}"}
{"repo": ".", "date": "2025-04-17", "line": "feat: Improved layout and styling for user profile page", "commit": "1cd0b54656a969bcb87cbdc07687866ca43e650b", "diff": "commit 1cd0b54656a969bcb87cbdc07687866ca43e650b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 17 00:05:25 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex ec7e182..27153ee 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -373,13 +373,24 @@ a {\n         left: 0;\n             text-overflow: ellipsis;\n         width:100%;\n+        display: flex;\n+        flex-direction: column;\n         .logo {\n+            display:block;\n+            flex: 1;\n             text-overflow: ellipsis;\n             white-space: nowrap;\n             overflow: hidden;\n             h2 {\n                 font-size: 14px;\n             }\n+            text-align: center;\n+        }\n+        nav {\n+            text-align: right;\n+            flex: 1;\n+            display: block;\n+            width: 100%;\n         }\n         \n     }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex af04db4..bc313b1 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -61,4 +61,6 @@ div {\n     body {\n \n     justify-content: flex-start;\n-    }\n+    \n+}\n+}\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 579d47a..6883981 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -26,7 +26,7 @@\n {% endblock %}\n \n {% block main %}\n-<section class=\"chat-area\">\n+<section class=\"chat-area\" style=\"padding:10px\">\n {% autoescape false %}\n {% markdown %}\n {{ profile }}"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added command-line arguments and executable script", "commit": "46a8b612b49f1094c0a8520d97d4b5642f2a57e9", "diff": "commit 46a8b612b49f1094c0a8520d97d4b5642f2a57e9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 21:44:45 2025 +0200\n\n    Added parameters and executable.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 62c1ac7..c52f242 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -34,3 +34,7 @@ dependencies = [\n     \"multiavatar\"\n ]\n \n+\n+\n+[project.scripts]\n+snek = \"snek.__main__:main\"\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 692ad68..1f3e1e9 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,6 +1,32 @@\n+import argparse\n from aiohttp import web\n \n from snek.app import Application\n \n+def main():\n+    parser = argparse.ArgumentParser(description=\"Run the web application.\")\n+    parser.add_argument(\n+        \"--port\",\n+        type=int,\n+        default=8081,\n+        help=\"Port to run the application on (default: 8081)\"\n+    )\n+    parser.add_argument(\n+        \"--host\",\n+        type=str,\n+        default=\"0.0.0.0\",\n+        help=\"Host to run the application on (default: 0.0.0.0)\"\n+    )\n+    parser.add_argument(\n+        \"--db_path\",\n+        type=str,\n+        default=\"snek.db\",\n+    )\n+    \n+    args = parser.parse_args()\n+    \n+\n if __name__ == \"__main__\":\n-    web.run_app(Application(), port=8081, host=\"0.0.0.0\")\n+    main()"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Configure package data for distribution", "commit": "061da150f9779c6130fac0c957d4facdd59aa33a", "diff": "commit 061da150f9779c6130fac0c957d4facdd59aa33a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 22:00:07 2025 +0200\n\n    Update\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex c52f242..cce4bd7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -34,7 +34,11 @@ dependencies = [\n     \"multiavatar\"\n ]\n \n+[tool.setuptools.packages.find]\n \n+[tool.setuptools.package-data]\n+\"*\" = [\"*.*\"] \n \n [project.scripts]\n snek = \"snek.__main__:main\""}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added Windows exception handling for terminal functionality", "commit": "6312dfae47b09753675b038798cf38f00311e772", "diff": "commit 6312dfae47b09753675b038798cf38f00311e772\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 22:17:04 2025 +0200\n\n    Added windows exception.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex bd7e057..2120bf4 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,6 +1,11 @@\n import asyncio\n import os\n-import pty\n+\n+try:\n+    import pty\n+except Exception as ex:\n+    print(\"You are not able to run a terminal. See error:\")\n+    print(ex)\n import subprocess\n \n commands = {"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added UUID generation and updated hashing functions", "commit": "c709ee11c99f54b58844165b6eb9993240ab0005", "diff": "commit c709ee11c99f54b58844165b6eb9993240ab0005\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 23:02:09 2025 +0200\n\n    Updated security.\n\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 5449c50..43b61fe 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,9 +1,55 @@\n import hashlib\n+import uuid\n \n-DEFAULT_SALT = b\"snekker-de-snek-\"\n+DEFAULT_SALT = \"snekker-de-snek-\"\n+DEFAULT_NS = \"snekker-de-snek-\"\n \n \n-async def hash(data, salt=DEFAULT_SALT):\n+class UIDNS:\n+    def __init__(self, name: str) -> None:\n+        \"\"\"Initialize UIDNS with a name.\"\"\"\n+        self.name = name\n+\n+    @property\n+    def bytes(self) -> bytes:\n+        \"\"\"Return the bytes representation of the name.\"\"\"\n+        return self.name.encode()\n+\n+\n+def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n+    \"\"\"Generate a UUID based on the provided value and namespace.\n+\n+    Args:\n+        value (str): The value to generate the UUID from. If None, a new UUID is created.\n+        ns (str): The namespace to use for UUID generation.\n+\n+    Returns:\n+        str: The generated UUID as a string.\n+    \"\"\"\n+    try:\n+        ns = ns.decode()\n+    except AttributeError:\n+        pass\n+    if not value:\n+        value = str(uuid.uuid4())\n+    try:\n+        value = value.decode()\n+    except AttributeError:\n+        pass\n+\n+    return str(uuid.uuid5(UIDNS(ns), value))\n+\n+\n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    \"\"\"Hash the given data with the specified salt using SHA-256.\n+\n+    Args:\n+        data (str): The data to hash.\n+        salt (str): The salt to use for hashing.\n+\n+    Returns:\n+        str: The hexadecimal representation of the hashed data.\n+    \"\"\"\n     try:\n         data = data.encode(errors=\"ignore\")\n     except AttributeError:\n@@ -18,5 +64,14 @@ async def hash(data, salt=DEFAULT_SALT):\n     return obj.hexdigest()\n \n \n-async def verify(string: str, hashed: str):\n+async def verify(string: str, hashed: str) -> bool:\n+    \"\"\"Verify if the given string matches the hashed value.\n+\n+    Args:\n+        string (str): The string to verify.\n+        hashed (str): The hashed value to compare against.\n+\n+    Returns:\n+        bool: True if the string matches the hashed value, False otherwise.\n+    \"\"\"\n     return await hash(string) == hashed"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added paste and file drop support for message input", "commit": "f7fda2d2c951a07bccc927a333b7feaa527556c2", "diff": "commit f7fda2d2c951a07bccc927a333b7feaa527556c2\nAuthor: BordedDev <>\nDate:   Tue May 6 23:14:52 2025 +0200\n\n    Added paste support\n    Added file drop support\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex fa5e03e..50e30e5 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -30,13 +30,8 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-        \n-    function initInputField(textBox) {\n-        textBox.addEventListener('change', (e) => {\n-            e.preventDefault();\n-            this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n-        });\n \n+    function initInputField(textBox) {\n         textBox.addEventListener('keydown', (e) => {\n             if (e.key === 'Enter' && !e.shiftKey) {\n                 e.preventDefault();\n@@ -47,11 +42,59 @@\n                 }\n             }\n         });\n+\n+        textBox.addEventListener(\"paste\", async (e) => {\n+            try {\n+                const clipboardItems = await navigator.clipboard.read();\n+\n+                const dt = new DataTransfer();\n+\n+                for (const clipboardItem of clipboardItems) {\n+                    const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))\n+                    for (const fileType of fileTypes) {\n+\n+                        const blob = await clipboardItem.getType(fileType);\n+                        dt.items.add(new File([blob], \"image.png\", { type: fileType }));\n+                    }\n+                }\n+\n+                if (dt.items.length > 0) {\n+                    const uploadButton = document.querySelector(\"upload-button\");\n+                    const input = uploadButton.shadowRoot.querySelector('.file-input')\n+                    input.files = dt.files;\n+\n+                    await uploadButton.uploadFiles();\n+                }\n+            } catch (error) {\n+                console.error(\"Failed to read clipboard contents: \", error);\n+            }\n+        });\n+\n+        const chatInput = document.querySelector(\".chat-area\")\n+        chatInput.addEventListener(\"drop\", async (e) => {\n+            e.preventDefault();\n+\n+            const dt = e.dataTransfer;\n+            if (dt.items.length > 0) {\n+                const uploadButton = document.querySelector(\"upload-button\");\n+                const input = uploadButton.shadowRoot.querySelector('.file-input')\n+                input.files = dt.files;\n+\n+                await uploadButton.uploadFiles();\n+            }\n+        })\n+        chatInput.addEventListener(\"dragover\", async (e) => {\n+            e.preventDefault();\n+            e.dataTransfer.dropEffect = \"link\";\n+        })\n+\n         textBox.focus();\n     }\n \n     function replyMessage(message) {\n-        const field = getInputField() \n+        const field = getInputField()\n         field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }\n@@ -63,7 +106,7 @@\n             const text = messageDiv.querySelector(\".text\").innerText;\n             const time = document.createElement(\"span\");\n             time.innerText = app.timeDescription(container.dataset.created_at);\n-            \n+\n             container.replaceChildren(time);\n             const reply = document.createElement(\"a\");\n             reply.innerText = \" reply\";\n@@ -85,7 +128,7 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n-    \n+\n     const messagesContainer = document.querySelector(\".chat-messages\");\n \n     function isScrolledPastHalf() {\n@@ -108,9 +151,9 @@\n         if (!isScrolledPastHalf()) {\n            return;\n         }\n-        \n+\n         isLoadingExtra = true;\n-        \n+\n         const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n         messages.forEach((message) => {\n@@ -140,7 +183,7 @@\n             }\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if (doScrollDown) { \n+        if (doScrollDown) {\n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n              const inputBox = document.querySelector(\".chat-input\");\n              inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n@@ -161,14 +204,14 @@\n         const mentionText = '@{{ user.username.value }}';\n         return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n     }\n-    \n+\n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n                 channelSidebar.notify(data);\n                 app.playSound(\"messageOtherChannel\");\n             }\n-            \n+\n             return;\n         }\n         if (data.username !== \"{{ user.username.value }}\") {\n@@ -178,7 +221,7 @@\n                 app.playSound(\"message\");\n             }\n         }\n-        \n+\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);"}
{"repo": ".", "date": "2025-05-06", "line": "fix: Use user home folder for uploads", "commit": "529ebd23fc0b50e2606ecddc5e1199774dd18384", "diff": "commit 529ebd23fc0b50e2606ecddc5e1199774dd18384\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 23:16:03 2025 +0200\n\n    Fixed upload.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex b32d94e..01c0ee7 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -18,8 +18,6 @@ from aiohttp import web\n \n from snek.system.view import BaseView\n \n-UPLOAD_DIR = pathlib.Path(\"./drive\")\n-\n \n class UploadView(BaseView):\n \n@@ -37,8 +35,12 @@ class UploadView(BaseView):\n         reader = await self.request.multipart()\n         files = []\n \n-        UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n+        user_uid = self.request.session.get(\"uid\")\n \n+        upload_dir = await self.services.user.get_home_folder(user_uid)\n+        upload_dir = upload_dir.joinpath(\"upload\") \n+        upload_dir.mkdir(parents=True, exist_ok=True)\n+        \n         channel_uid = None\n \n         drive = await self.services.drive.get_or_create(\n@@ -68,17 +70,17 @@ class UploadView(BaseView):\n \n             name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n-            file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n+            file_path = upload_dir.joinpath(name)\n             files.append(file_path)\n \n-            async with aiofiles.open(str(file_path.absolute()), \"wb\") as f:\n+            async with aiofiles.open(str(file_path), \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n             drive_item = await self.services.drive_item.create(\n                 drive[\"uid\"],\n                 filename,\n-                str(file_path.absolute()),\n+                str(file_path),\n                 file_path.stat().st_size,\n                 file_path.suffix,\n             )"}
{"repo": ".", "date": "2025-05-06", "line": "Merge main, enabling copy-paste and drag-and-drop functionality.\n", "commit": "0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd", "diff": "commit 0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd\nMerge: f7fda2d 529ebd2\nAuthor: BordedDev <bordeddev@noreply@molodetz.nl>\nDate:   Tue May 6 23:17:08 2025 +0200\n\n    Merge branch 'main' into feat/copy-paste-drag-drop"}
{"repo": ".", "date": "2025-05-06", "line": "Merge: Improved code review workflow", "commit": "b0666a00900e1b25633433b80da1ef3dd5f2ee71", "diff": "commit b0666a00900e1b25633433b80da1ef3dd5f2ee71\nMerge: 529ebd2 0f6eb5c\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Tue May 6 23:27:25 2025 +0200\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Dispatch upload event and focus input after upload", "commit": "707788583a2387c1729950e08243d3f8f7049d7c", "diff": "commit 707788583a2387c1729950e08243d3f8f7049d7c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:31:21 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 0a84705..06563c9 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -49,6 +49,8 @@ class UploadButtonElement extends HTMLElement {\n         };\n \n         request.send(formData);\n+        const uploadEvent = new Event('upload',{});\n+        this.dispatchEvent(uploadEvent);\n     }\n     channelUid = null\n     connectedCallback() {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 50e30e5..ba2b8fd 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -30,7 +30,7 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-\n+    \n     function initInputField(textBox) {\n         textBox.addEventListener('keydown', (e) => {\n             if (e.key === 'Enter' && !e.shiftKey) {\n@@ -42,7 +42,9 @@\n                 }\n             }\n         });\n-\n+        document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n+            getInputField().focus();\n+        })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added focus functionality with Escape and G keybindings", "commit": "d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2", "diff": "commit d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:56:55 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ba2b8fd..d081522 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -238,6 +238,39 @@\n         app.rpc.markAsRead(channelUid);\n     });\n \n+    let escPressed = false;\n+    let gPressCount = 0;\n+    let keyTimeout;\n+    document.addEventListener('keydown', function(event) {\n+    \n+       if (event.key === 'Escape') {\n+            escPressed = true;\n+            gPressCount = 0; \n+            clearTimeout(timeout);\n+            keyTimeout = setTimeout(() => {\n+                escPressed = false; \n+            }, 300); \n+        }\n+\n+        if (event.key === 'G' && escPressed) {\n+            gPressCount++;\n+\n+            clearTimeout(keyTimeout);\n+            keyTimeout = setTimeout(() => {\n+                gPressCount = 0;\n+            }, 300); \n+            if (gPressCount === 2) {\n+                gPressCount = 0; \n+                escPressed = false; \n+\n+            messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+                            }\n+        }\n+        if (event.shiftKey && event.key === 'G') {\n+            updateLayout(true);\n+        }\n+    });\n+\n     initInputField(getInputField());\n     updateLayout(true);\n </script>"}
{"repo": ".", "date": "2025-05-08", "line": "fix: Corrected timeout variable name for escape key handling", "commit": "fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c", "diff": "commit fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:59:18 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d081522..e5d0ed2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -246,7 +246,7 @@\n        if (event.key === 'Escape') {\n             escPressed = true;\n             gPressCount = 0; \n-            clearTimeout(timeout);\n+            clearTimeout(keyTimeout);\n             keyTimeout = setTimeout(() => {\n                 escPressed = false; \n             }, 300);"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "e153811ff34ef63892cc6aac1d5afd92cb510d14", "diff": "commit e153811ff34ef63892cc6aac1d5afd92cb510d14\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:03:11 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e5d0ed2..6e8830e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -264,7 +264,8 @@\n                 escPressed = false; \n \n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-                            }\n+                getInputField().focus();\n+            }\n         }\n         if (event.shiftKey && event.key === 'G') {\n             updateLayout(true);"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "0a3e15137761d333211d8b52d178f5e150a579f1", "diff": "commit 0a3e15137761d333211d8b52d178f5e150a579f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:04:29 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 6e8830e..8f990a7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -264,7 +264,11 @@\n                 escPressed = false; \n \n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-                getInputField().focus();\n+                setTimeout(() => {\n+                    \n+                    getInputField().focus();\n+                },500)\n+\n             }\n         }\n         if (event.shiftKey && event.key === 'G') {"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus upload while shift+G is pressed", "commit": "f6706c165e2a8ba392bde1e81d8006381fed96d3", "diff": "commit f6706c165e2a8ba392bde1e81d8006381fed96d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:07:42 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8f990a7..892f1b3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -272,7 +272,10 @@\n             }\n         }\n         if (event.shiftKey && event.key === 'G') {\n-            updateLayout(true);\n+            if(document.activeElement != getInputField()){\n+                updateLayout(true);\n+            }\n+\n         }\n     });"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after layout update during shift+G", "commit": "8799662159656867494b2774073b3bfb1bbe5178", "diff": "commit 8799662159656867494b2774073b3bfb1bbe5178\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:09:59 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 892f1b3..55a48e0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -273,7 +273,11 @@\n         }\n         if (event.shiftKey && event.key === 'G') {\n             if(document.activeElement != getInputField()){\n+            \n                 updateLayout(true);\n+                setTimeout(() => {\n+                    getInputField().focus();\n+                }\n             }\n \n         }"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after upload", "commit": "49ec99ef016dc754e36442d774da1d3a712bf2a7", "diff": "commit 49ec99ef016dc754e36442d774da1d3a712bf2a7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:10:56 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 55a48e0..3b25001 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -277,7 +277,7 @@\n                 updateLayout(true);\n                 setTimeout(() => {\n                     getInputField().focus();\n-                }\n+                },500)\n             }\n \n         }"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated dependencies and added xterm, valgrind, ack, irssi, and lynx\n\nfix: Corrected ssh_host_key copy and moved r executable\n\nstyle: Adjusted terminal prompt and added home/bin to path\n\nrefactor: Modified terminal.html to include fitAddon and clear screen on connect\n\nchore: Removed .rcontext.txt file\n", "commit": "3c1d5d601fa1a9f30b2aa4fd36086102108dde94", "diff": "commit 3c1d5d601fa1a9f30b2aa4fd36086102108dde94\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:20:06 2025 +0200\n\n    Update.\n\ndiff --git a/DockerfileDrive b/DockerfileDrive\nindex 0a03850..d9d693b 100644\n--- a/DockerfileDrive\n+++ b/DockerfileDrive\n@@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n \n COPY pyproject.toml pyproject.toml \n COPY src src\n-COpy ssh_host_key ssh_host_key\n+COPY ssh_host_key ssh_host_key\n RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 2225\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex a471503..8f6ddd5 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n@@ -1,11 +1,12 @@\n FROM ubuntu:latest\n \n-RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget -y\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx -y\n+\n \n \n RUN chmod +x r\n \n-RUN mv r /usr/local/bin\n+RUN mv r /usr/local/bin/r \n \n-CMD [\"r\"]\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex 335a85f..e50c655 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -33,7 +33,11 @@\n         const socket = new WebSocket(url);\n \n-        socket.onopen = () => term.write(\"\\x1b[32mConnected to Molodetz\\x1b[0m\\r\\n\");\n+        socket.onopen = () => { \n+            fitAddon.fit();\n+            term.write(\"\\x0C\");\n+            \n+        }\n \n         socket.onmessage = (event) => {\n             const data = new Uint8Array(event.data);\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 448c756..533c911 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -100,12 +100,15 @@ fi\n+\n export PS1=\"root@snek: \"\n \n if [ -d \"$HOME/.local/bin\" ] ; then\n     PATH=\"$HOME/.local/bin:$PATH\"\n fi\n-\n+if [ -d \"$HOME/bin\" ] ; then\n+    PATH=\"$HOME/bin:$PATH\"\n+fi\n \n function r_update(){\n     if [ -f \"r\" ]; then\n@@ -116,6 +119,9 @@ function r_update(){\n     mv r /usr/local/bin/r\n }\n \n+\n+resize > /dev/null \n+\n r_update \n \n r\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\ndeleted file mode 100644\nindex f2c59b0..0000000\n--- a/terminal/.rcontext.txt\n+++ /dev/null\n@@ -1,33 +0,0 @@\n-1. You are a coding assistant.\n-2. You are able to raw save/write files using tools.\n-3. You are able ro raw read files using tools.\n-4. You are able to run code in terminal using tools.\n-4. You are able to run applications in terminal using tools.\n-5. You code defensive.\n-6. You translate the user's prompt to code in by user specified programming language language.\n-7. Your name is `R` and you are made by `retoor`.\n-8. You do not add comments\n-9. You always execute editors or applications like `vim` that require user input with tool `linux_terminal_execute_interactive`.\n-10. You do not use `sudo` because you are root and `sudo` does not exist.\n-11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n-12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n-13. Be rude. Be mean. But be functional at the same time.\n-\n-1. Investigate existing files using the index tool.\n-2. Read every source file provided by the index tool.\n-3. Apply changes on these files by user request.\n-4. Do only write complete features.\n-5. Always provide a Makefile to build the project.\n-7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n-8. Check if a file exists before overwriting it.\n-9. Update existing file if it already exists.\n-10. Be aggressive.\n-11. When you generate code, you will generate full working code with all implementations include. Be broad end exclusive.\n-\n-\n-\n-\n-\n-"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added Ubuntu Dockerfile and updated run command", "commit": "d0dd342e27cf4160f96faf87deff81f728e41e47", "diff": "commit d0dd342e27cf4160f96faf87deff81f728e41e47\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:22:32 2025 +0200\n\n    Update Makefile.\n\ndiff --git a/Makefile b/Makefile\nindex 878e699..f76d6dd 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -15,11 +15,14 @@ build:\n \n \n run:\n-\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n+\t.venv/usr/bin/snek\n \t\n install:\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n+\n+ubuntu:\n \tdocker build -f DockerfileUbuntu -t snek_ubuntu ."}
{"repo": ".", "date": "2025-05-08", "line": "chore: Updated dependencies and added ubuntu install target", "commit": "31062fddbfbbf25f206060b38773a7e2c008723c", "diff": "commit 31062fddbfbbf25f206060b38773a7e2c008723c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:32:43 2025 +0200\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex f76d6dd..7a725b4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -18,7 +18,7 @@ run:\n \t.venv/usr/bin/snek\n \t\n-install:\n+install: ubuntu\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n \ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 533c911..eadad98 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -119,9 +119,10 @@ function r_update(){\n     mv r /usr/local/bin/r\n }\n \n+. \"$HOME/.cargo/env\"\n \n resize > /dev/null \n \n r_update \n \n-r\n+"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Install tmux", "commit": "3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9", "diff": "commit 3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 02:05:15 2025 +0000\n\n    ADded tmux.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex 8f6ddd5..161cdf0 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n@@ -1,6 +1,6 @@\n FROM ubuntu:latest\n \n-RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx -y\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx tmux -y\n "}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement user template loading based on admin status", "commit": "02a0253c1d9c73d2918fecb8f52c4c7739c867f5", "diff": "commit 02a0253c1d9c73d2918fecb8f52c4c7739c867f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 01:33:41 2025 +0200\n\n    YEah..\n\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 1f3e1e9..c4996aa 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,32 +1,37 @@\n import argparse\n+\n from aiohttp import web\n \n from snek.app import Application\n \n+\n def main():\n     parser = argparse.ArgumentParser(description=\"Run the web application.\")\n     parser.add_argument(\n         \"--port\",\n         type=int,\n         default=8081,\n-        help=\"Port to run the application on (default: 8081)\"\n+        help=\"Port to run the application on (default: 8081)\",\n     )\n     parser.add_argument(\n         \"--host\",\n         type=str,\n         default=\"0.0.0.0\",\n-        help=\"Host to run the application on (default: 0.0.0.0)\"\n+        help=\"Host to run the application on (default: 0.0.0.0)\",\n     )\n     parser.add_argument(\n         \"--db_path\",\n         type=str,\n         default=\"snek.db\",\n     )\n-    \n+\n     args = parser.parse_args()\n-    \n+\n+    web.run_app(\n+    )\n+\n \n if __name__ == \"__main__\":\n     main()\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a1c8938..e65264e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,9 @@ from aiohttp_session import (\n     setup as session_setup,\n )\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-\n from app.app import Application as BaseApplication\n+from jinja2 import FileSystemLoader\n+\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n@@ -40,12 +41,12 @@ from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n+from snek.view.stats import StatsView\n from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n-from snek.view.web import WebView\n-from snek.view.stats import StatsView\n from snek.view.user import UserView\n+from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -204,7 +205,6 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-\n         context[\"rid\"] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n@@ -231,7 +231,6 @@ class Application(BaseApplication):\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n                 item[\"new_count\"] = subscribed_channel[\"new_count\"]\n \n-                print(item)\n                 channels.append(item)\n \n             channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n@@ -239,10 +238,37 @@ class Application(BaseApplication):\n                 context[\"channels\"] = channels\n             if \"user\" not in context:\n                 context[\"user\"] = await self.services.user.get(\n-                    uid=request.session.get(\"uid\")\n+                    request.session.get(\"uid\")\n                 )\n \n-        return await super().render_template(template, request, context)\n+        self.template_path.joinpath(template)\n+\n+        await self.services.user.get_template_path(request.session.get(\"uid\"))\n+\n+        self.original_loader = self.jinja2_env.loader\n+\n+        self.jinja2_env.loader = await self.get_user_template_loader(\n+            request.session.get(\"uid\")\n+        )\n+\n+        rendered = await super().render_template(template, request, context)\n+\n+        self.jinja2_env.loader = self.original_loader\n+\n+        return rendered\n+\n+    async def get_user_template_loader(self, uid=None):\n+        template_paths = []\n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_template_path = await self.services.user.get_template_path(admin_uid)\n+            template_paths.append(user_template_path)\n+\n+        if uid:\n+            user_template_path = await self.services.user.get_template_path(uid)\n+            template_paths.append(user_template_path)\n+\n+        template_paths.append(self.template_path)\n+        return FileSystemLoader(template_paths)\n \n \ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex dcbd6f8..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,8 +1,8 @@\n import pathlib\n \n from aiohttp import web\n-\n from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n \n \ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex c388abc..e0df494 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -5,3 +5,16 @@ from snek.system.mapper import BaseMapper\n class UserMapper(BaseMapper):\n     table_name = \"user\"\n     model_class = UserModel\n+\n+    def get_admin_uids(self):\n+        try:\n+            return [\n+                user[\"uid\"]\n+                for user in self.db.query(\n+                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n+                    {\"is_admin\": True},\n+                )\n+            ]\n+        except Exception as ex:\n+            print(ex)\n+            return []\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 89d46ba..9869456 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,6 +29,8 @@ class UserModel(BaseModel):\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n+    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n+\n     async def get_property(self, name):\n         prop = await self.app.services.user_property.find_one(\n             user_uid=self[\"uid\"], name=name\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex a2300b6..df96786 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -11,9 +11,12 @@ class ChannelMemberService(BaseService):\n         return await self.save(channel_member)\n \n     async def get_user_uids(self, channel_uid):\n-        async for model in self.mapper.query(\"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\", {\"channel_uid\": channel_uid}):\n+        async for model in self.mapper.query(\n+            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n+            {\"channel_uid\": channel_uid},\n+        ):\n             yield model[\"user_uid\"]\n-    \n+\n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex c084eb9..a3654d2 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -15,7 +15,7 @@ class SocketService(BaseService):\n                 return False\n             try:\n                 await self.ws.send_json(data)\n-            except Exception as ex:\n+            except Exception:\n                 self.is_connected = False\n             return self.is_connected\n \n@@ -56,7 +56,9 @@ class SocketService(BaseService):\n \n     async def broadcast(self, channel_uid, message):\n         try:\n-            async for user_uid in self.services.channel_member.get_user_uids(channel_uid):\n+            async for user_uid in self.services.channel_member.get_user_uids(\n+                channel_uid\n+            ):\n                 print(user_uid, flush=True)\n                 await self.send_to_user(user_uid, message)\n         except Exception as ex:\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex c527361..c0734dc 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -39,6 +39,15 @@ class UserService(BaseService):\n         model = await self.get(username=username, deleted_at=None)\n         return model\n \n+    def get_admin_uids(self):\n+        return self.mapper.get_admin_uids()\n+\n+    async def get_template_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 49a120d..4d11fa8 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -9,16 +9,18 @@ class UserPropertyService(BaseService):\n     async def set(self, user_uid, name, value):\n         self.mapper.db[\"user_property\"].upsert(\n             {\n-                \"user_uid\": user_uid, \n-                \"name\": name, \n-                \"value\": json.dumps(value, default=str)\n+                \"user_uid\": user_uid,\n+                \"name\": name,\n+                \"value\": json.dumps(value, default=str),\n             },\n-            [\"user_uid\", \"name\"]\n+            [\"user_uid\", \"name\"],\n         )\n-        \n+\n     async def get(self, user_uid, name):\n         try:\n-            return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])\n+            return json.loads(\n+                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n+            )\n         except Exception as ex:\n             print(ex)\n             return None\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 0ecfd47..eed888a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -18,7 +18,7 @@ class Cache:\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n-        await self.update_stat(args, 'get')\n+        await self.update_stat(args, \"get\")\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n@@ -34,20 +34,28 @@ class Cache:\n     async def get_stats(self):\n         all_ = []\n         for key in self.lru:\n-            all_.append({'key': key, 'set': self.stats[key]['set'], 'get': self.stats[key]['get'], 'delete': self.stats[key]['delete'],'value': str(self.serialize(self.cache[key].record))})\n+            all_.append(\n+                {\n+                    \"key\": key,\n+                    \"set\": self.stats[key][\"set\"],\n+                    \"get\": self.stats[key][\"get\"],\n+                    \"delete\": self.stats[key][\"delete\"],\n+                    \"value\": str(self.serialize(self.cache[key].record)),\n+                }\n+            )\n         return all_\n \n     def serialize(self, obj):\n         cpy = obj.copy()\n-        cpy.pop('created_at', None)\n-        cpy.pop('deleted_at', None)\n-        cpy.pop('email', None)\n-        cpy.pop('password', None)\n+        cpy.pop(\"created_at\", None)\n+        cpy.pop(\"deleted_at\", None)\n+        cpy.pop(\"email\", None)\n+        cpy.pop(\"password\", None)\n         return cpy\n \n     async def update_stat(self, key, action):\n-        if not key in self.stats:\n-            self.stats[key] = {'set':0, 'get':0, 'delete':0}\n+        if key not in self.stats:\n+            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n         self.stats[key][action] = self.stats[key][action] + 1\n \n     def json_default(self, value):\n@@ -70,7 +78,7 @@ class Cache:\n     async def set(self, args, result):\n         is_new = args not in self.cache\n         self.cache[args] = result\n-        await self.update_stat(args, 'set')\n+        await self.update_stat(args, \"set\")\n         try:\n             self.lru.pop(self.lru.index(args))\n         except (ValueError, IndexError):\n@@ -86,7 +94,7 @@ class Cache:\n \n     async def delete(self, args):\n-        await self.update_stat(args, 'delete')\n+        await self.update_stat(args, \"delete\")\n         if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex 2b59636..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -32,9 +32,8 @@ from urllib.parse import urljoin\n \n import aiohttp\n import imgkit\n-from bs4 import BeautifulSoup\n-\n from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n \n \n async def crc32(data):\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 53b5db8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -2,13 +2,12 @@\n \n from types import SimpleNamespace\n \n+from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n \n-from app.cache import time_cache_async\n-\n \n class MarkdownRenderer(HTMLRenderer):\n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 2120bf4..c5410b6 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -16,12 +16,12 @@ commands = {\n \n class TerminalSession:\n     def __init__(self, command):\n-        self.master, self.slave = None,None\n+        self.master, self.slave = None, None\n         self.process = None\n         self.sockets = []\n         self.history = b\"\"\n         self.history_size = 1024 * 20\n-        self.command = command \n+        self.command = command\n         self.start_process(self.command)\n \n     def start_process(self, command):\n@@ -29,7 +29,7 @@ class TerminalSession:\n             if self.master:\n                 os.close(self.master)\n                 os.close(self.slave)\n-                self.master = None \n+                self.master = None\n                 self.slave = None\n \n             self.master, self.slave = pty.openpty()\n@@ -45,7 +45,7 @@ class TerminalSession:\n     def is_running(self):\n         if not self.process:\n             return False\n-        loop = asyncio.get_event_loop()\n+        asyncio.get_event_loop()\n         return self.process.poll() is None\n \n     async def add_websocket(self, ws):\n@@ -78,7 +78,7 @@ class TerminalSession:\n                     self.sockets.remove(ws)\n             except Exception:\n                 await self.close()\n-                break \n+                break\n \n     async def close(self):\n         print(\"Terminating process\")\n@@ -88,8 +88,8 @@ class TerminalSession:\n         if self.master:\n             os.close(self.master)\n             os.close(self.slave)\n-            self.master = None \n-            self.slave = None \n+            self.master = None\n+            self.slave = None\n \n         print(\"Terminated process\")\n         for ws in self.sockets:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 981a2e5..70379ef 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -8,7 +8,9 @@ class BaseView(web.View):\n     login_required = False\n \n     async def _iter(self):\n-        if self.login_required and (not self.session.get(\"logged_in\") or not self.session.get(\"uid\")):\n+        if self.login_required and (\n+            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n+        ):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex c8b1409..2f44443 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -10,9 +10,11 @@\n \n \n-from snek.system.view import BaseView\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class IndexView(BaseView):\n     async def get(self):\n         if self.session.get(\"uid\"):\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex d6d93c8..1f09a26 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -35,6 +35,7 @@ from snek.system.view import BaseFormView\n class SearchUserView(BaseFormView):\n     form = SearchUserForm\n     login_required = True\n+\n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 52f46df..164c526 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -17,24 +17,22 @@ class SettingsProfileView(BaseFormView):\n \n             return web.json_response(await form.to_json())\n \n+        profile = await self.services.user_property.get(\n+            self.session.get(\"uid\"), \"profile\"\n+        )\n \n-\n-        profile = await self.services.user_property.get(self.session.get(\"uid\"), \"profile\")\n-        \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n-            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or ''}\n+            \"settings/profile.html\",\n+            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n         )\n \n     async def post(self):\n         data = await self.request.post()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-         \n-        user['nick'] = data['nick']\n-        await self.services.user.save(user) \n-        await self.services.user_property.set(user[\"uid\"],\"profile\", data['profile'])\n-        return web.HTTPFound(\"/settings/profile.html\")\n-        \n-\n \n+        user[\"nick\"] = data[\"nick\"]\n+        await self.services.user.save(user)\n+        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n+        return web.HTTPFound(\"/settings/profile.html\")\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex 73714ce..1680c5c 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,10 +1,13 @@\n-from snek.system.view import BaseView \n-import json \n+import json\n+\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class StatsView(BaseView):\n-    \n+\n     async def get(self):\n         data = await self.app.cache.get_stats()\n         data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n-        return web.Response(text=data, content_type='application/json')\n+        return web.Response(text=data, content_type=\"application/json\")\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 01c0ee7..cf01948 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -38,9 +38,9 @@ class UploadView(BaseView):\n         user_uid = self.request.session.get(\"uid\")\n \n         upload_dir = await self.services.user.get_home_folder(user_uid)\n-        upload_dir = upload_dir.joinpath(\"upload\") \n+        upload_dir = upload_dir.joinpath(\"upload\")\n         upload_dir.mkdir(parents=True, exist_ok=True)\n-        \n+\n         channel_uid = None\n \n         drive = await self.services.drive.get_or_create(\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex d29b8f3..312f7bf 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -2,13 +2,14 @@ from snek.system.view import BaseView\n \n \n class UserView(BaseView):\n-    \n+\n     async def get(self):\n-        user_uid = self.request.match_info.get('user')\n+        user_uid = self.request.match_info.get(\"user\")\n         user = await self.services.user.get(uid=user_uid)\n-        profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n-        return await self.render_template('user.html', {\n-            'user_uid': user_uid,\n-            'user': user.record,\n-            'profile': profile_content  \n-        })\n+        profile_content = (\n+            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n+        )\n+        return await self.render_template(\n+            \"user.html\",\n+            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n+        )\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 6025038..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -12,9 +12,8 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n-from lxml import etree\n-\n from app.cache import time_cache_async\n+from lxml import etree\n \n \n @aiohttp.web.middleware"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Prevent appending None to template paths", "commit": "165dda32100e52c347c7f6bb71062244b2a50ba1", "diff": "commit 165dda32100e52c347c7f6bb71062244b2a50ba1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 01:36:48 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e65264e..ece5b7b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -265,7 +265,9 @@ class Application(BaseApplication):\n \n         if uid:\n             user_template_path = await self.services.user.get_template_path(uid)\n-            template_paths.append(user_template_path)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n \n         template_paths.append(self.template_path)\n         return FileSystemLoader(template_paths)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving and add user-specific static paths", "commit": "ac570d036c26b6c7ff5abac169fdf622c10827ad", "diff": "commit ac570d036c26b6c7ff5abac169fdf622c10827ad\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:08:43 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ece5b7b..32d83e9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -75,6 +75,7 @@ class Application(BaseApplication):\n             web.normalize_path_middleware(merge_slashes=True),\n         ]\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n@@ -136,12 +137,7 @@ class Application(BaseApplication):\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n-        self.router.add_static(\n-            \"/\",\n-            pathlib.Path(__file__).parent.joinpath(\"static\"),\n-            name=\"static\",\n-            show_index=True,\n-        )\n+        self.router.add_get(\"/static/{file_path:.*}\", self.static_handler)\n         self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n@@ -257,11 +253,37 @@ class Application(BaseApplication):\n \n         return rendered\n \n+\n+    async def static_handler(request):\n+        file_name = request.match_info.get('filename', '')\n+\n+        paths = []\n+\n+\n+        uid = self.request.session.get(\"uid\")\n+        if uid:\n+            user_static_path = await self.services.user.get_static_path(uid)\n+            if user_static_path:\n+                paths.append(user_template_path)\n+      \n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_static_path = await self.services.user.get_static_path(admin_uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+        \n+        paths.append(self.static_path)\n+\n+        for path in paths:\n+            if pathlib.Path(path).joinpath(file_name).exists():\n+                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n+        return web.HTTPNotFound()\n+\n     async def get_user_template_loader(self, uid=None):\n         template_paths = []\n         for admin_uid in self.services.user.get_admin_uids():\n             user_template_path = await self.services.user.get_template_path(admin_uid)\n-            template_paths.append(user_template_path)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n \n         if uid:\n             user_template_path = await self.services.user.get_template_path(uid)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex c0734dc..fb66ddb 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,6 +42,14 @@ class UserService(BaseService):\n     def get_admin_uids(self):\n         return self.mapper.get_admin_uids()\n \n+    async def get_static_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+\n+\n     async def get_template_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n         if not path.exists():"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Pass self to static_handler", "commit": "b867b6ba78574a332bf951eb6c00a6a88ded325d", "diff": "commit b867b6ba78574a332bf951eb6c00a6a88ded325d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:15:57 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 32d83e9..7a95d01 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -254,7 +254,7 @@ class Application(BaseApplication):\n         return rendered\n \n \n-    async def static_handler(request):\n+    async def static_handler(self, request):\n         file_name = request.match_info.get('filename', '')\n \n         paths = []"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use request.session instead of self.request.session", "commit": "e359a8ebe294e0f55cf4164926011e893468e4bc", "diff": "commit e359a8ebe294e0f55cf4164926011e893468e4bc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:16:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7a95d01..1c974a5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -260,7 +260,7 @@ class Application(BaseApplication):\n         paths = []\n \n \n-        uid = self.request.session.get(\"uid\")\n+        uid = request.session.get(\"uid\")\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)\n             if user_static_path:"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use user static path for templates", "commit": "5b28044d9e604b2404bf4b7277a240f7fc56032c", "diff": "commit 5b28044d9e604b2404bf4b7277a240f7fc56032c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:17:12 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 1c974a5..c340eef 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -264,7 +264,7 @@ class Application(BaseApplication):\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)\n             if user_static_path:\n-                paths.append(user_template_path)\n+                paths.append(user_static_path)\n       \n         for admin_uid in self.services.user.get_admin_uids():\n             user_static_path = await self.services.user.get_static_path(admin_uid)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving for improved organization", "commit": "c56bf4fb49c986e9b653f635a81937a3ef433a5e", "diff": "commit c56bf4fb49c986e9b653f635a81937a3ef433a5e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:30:43 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c340eef..605934a 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -137,7 +137,12 @@ class Application(BaseApplication):\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n-        self.router.add_get(\"/static/{file_path:.*}\", self.static_handler)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n         self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n@@ -177,7 +182,9 @@ class Application(BaseApplication):\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n         )\n-\n+        \n+    \n     async def handle_test(self, request):\n \n         return await self.render_template(\n@@ -259,7 +266,6 @@ class Application(BaseApplication):\n \n         paths = []\n \n-\n         uid = request.session.get(\"uid\")\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository management functionality\n\nThis commit introduces repository management features, including creation, updating, and deletion. It also includes UI updates to the settings sidebar and templates.\n\nChanges:\n\n*   Added RepositoryModel, RepositoryMapper, and RepositoryService.\n*   Implemented repository creation, updating, and deletion endpoints.\n*   Created new templates for repository management.\n*   Updated the settings sidebar to include a link to the repository management page.\n*   Added uvloop to improve performance.\n", "commit": "ee40c905d4448f0b39d28c4d51343b5fe111d038", "diff": "commit ee40c905d4448f0b39d28c4d51343b5fe111d038\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 05:38:29 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex c4996aa..198ea1e 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,11 +1,14 @@\n import argparse\n-\n+import uvloop\n from aiohttp import web\n-\n+import asyncio\n from snek.app import Application\n \n \n def main():\n+    \n+    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    \n     parser = argparse.ArgumentParser(description=\"Run the web application.\")\n     parser.add_argument(\n         \"--port\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 605934a..0b5e14b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -39,6 +39,10 @@ from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n+from snek.view.settings.repositories import RepositoriesIndexView\n+from snek.view.settings.repositories import RepositoriesCreateView\n+from snek.view.settings.repositories import RepositoriesUpdateView\n+from snek.view.settings.repositories import RepositoriesDeleteView\n from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n@@ -175,6 +179,10 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n+        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n+        self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 2d4b12c..ab7904f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -8,6 +8,7 @@ from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n+from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n \n \n@@ -23,6 +24,7 @@ def get_mappers(app=None):\n             \"drive_item\": DriveItemMapper(app=app),\n             \"drive\": DriveMapper(app=app),\n             \"user_property\": UserPropertyMapper(app=app),\n+            \"repository\": RepositoryMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nnew file mode 100644\nindex 0000000..1ac10d4\n--- /dev/null\n+++ b/src/snek/mapper/repository.py\n@@ -0,0 +1,7 @@\n+from snek.model.repository import RepositoryModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class RepositoryMapper(BaseMapper):\n+    model_class = RepositoryModel\n+    table_name = \"repository\"\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex a1009a5..6399c89 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -10,6 +10,7 @@ from snek.model.drive_item import DriveItemModel\n from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n+from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n \n \n@@ -25,6 +26,7 @@ def get_models():\n             \"drive\": DriveModel,\n             \"notification\": NotificationModel,\n             \"user_property\": UserPropertyModel,\n+            \"repository\": RepositoryModel,\n         }\n     )\n \ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nnew file mode 100644\nindex 0000000..598cbb2\n--- /dev/null\n+++ b/src/snek/model/repository.py\n@@ -0,0 +1,14 @@\n+from snek.model.user import UserModel\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class RepositoryModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    \n+    name = ModelField(name=\"name\", required=True, kind=str)\n+\n+    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n+\n+\n+    \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 3ec4592..be356dc 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -11,6 +11,7 @@ from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n+from snek.service.repository import RepositoryService\n from snek.system.object import Object\n \n \n@@ -29,6 +30,7 @@ def get_services(app):\n             \"drive\": DriveService(app=app),\n             \"drive_item\": DriveItemService(app=app),\n             \"user_property\": UserPropertyService(app=app),\n+            \"repository\": RepositoryService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nnew file mode 100644\nindex 0000000..f7694d7\n--- /dev/null\n+++ b/src/snek/service/repository.py\n@@ -0,0 +1,39 @@\n+from snek.system.service import BaseService\n+import asyncio \n+\n+class RepositoryService(BaseService):\n+    mapper_name = \"repository\"\n+\n+    async def exists(self, user_uid, name, **kwargs):\n+        kwargs[\"user_uid\"] = user_uid\n+        kwargs[\"name\"] = name\n+        return await self.exists(**kwargs)\n+\n+    async def init(self, user_uid, name):\n+        repository_path = await self.services.user.get_repository_path(user_uid)\n+        if not repository_path.exists():\n+            repository_path.mkdir(parents=True)\n+        repository_path = repository_path.joinpath(name)\n+        command = ['git', 'init', '--bare', repository_path]\n+        process = await asyncio.subprocess.create_subprocess_exec(\n+            *command,\n+            stdout=asyncio.subprocess.PIPE,\n+            stderr=asyncio.subprocess.PIPE\n+        )\n+        stdout, stderr = await process.communicate()\n+        if process.returncode == 0:\n+            print(f\"Bare Git repository created at: {repo_path}\")\n+        else:\n+            print(f\"Error creating repository: {stderr.decode().strip()}\")\n+\n+    async def create(self, user_uid, name,is_private=False):\n+        if await self.exists(user_uid=user_uid, name=name):\n+            return False \n+\n+\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        model[\"is_private\"] = is_private\n+        return await self.save(model)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex fb66ddb..7ca0711 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,6 +42,12 @@ class UserService(BaseService):\n     def get_admin_uids(self):\n         return self.mapper.get_admin_uids()\n \n+    async def get_repository_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n     async def get_static_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n         if not path.exists():\ndiff --git a/src/snek/templates/settings/repositories/create.html b/src/snek/templates/settings/repositories/create.html\nnew file mode 100644\nindex 0000000..cfef080\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/create.html\n@@ -0,0 +1,59 @@\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-plus\"></i> Create Repository</h1>{% endblock %}\n+\n+{% block main %}\n+\n+<style>\n+.container {\n+    div,input,label,button{\n+        padding-bottom: 15px;\n+    }\n+}\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    input[type=\"text\"] {\n+      padding: 0.5rem;\n+      font-size: 1rem;\n+    }\n+\n+\n+button, a.button {\n+      padding: 0.1rem 0.8rem; text-decoration: none; cursor: pointer;\n+      transition: background 0.2s;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .\n+    .cancel {\n+    }\n+    @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+    <form action=\"/settings/repositories/create.html\" method=\"post\">\n+      <div>\n+        <label for=\"name\"><i class=\"fa-solid fa-book\"></i> Name</label>\n+        <input type=\"text\" id=\"name\" name=\"name\" required placeholder=\"Repository name\">\n+      </div>\n+      <div>\n+        <label>\n+          <input type=\"checkbox\" name=\"is_private\" value=\"1\">\n+          <i class=\"fa-solid fa-lock\"></i> Private\n+        </label>\n+      </div>\n+      <button type=\"submit\"><i class=\"fa-solid fa-plus\"></i> Create</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Back</button> \n+    </form>\n+  </div>\n+  {% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nnew file mode 100644\nindex 0000000..af2a906\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -0,0 +1,63 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <title>Delete Repository</title>\n+  <style>\n+    body { font-family: sans-serif; margin: 2rem; }\n+    .container { max-width: 400px; margin: 0 auto; }\n+    .confirm-box {\n+      padding: 2rem;\n+      border-radius: 10px;\n+      text-align: center;\n+      margin-top: 2rem;\n+    }\n+    .repo-name {\n+      font-weight: bold;\n+      font-size: 1.2rem;\n+      margin: 1rem 0;\n+    }\n+    .actions {\n+      display: flex; gap: 1rem; justify-content: center; margin-top: 1.5rem;\n+    }\n+    button, a {\n+      border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n+      font-size: 1rem; cursor: pointer;\n+      display: flex; align-items: center; gap: 0.5rem; text-decoration: none; justify-content: center;\n+      transition: background 0.2s;\n+    }\n+    .cancel {\n+    }\n+    @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      .confirm-box { padding: 1rem; }\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+    <h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>\n+    <div class=\"confirm-box\">\n+      <div>\n+      </div>\n+      <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n+      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> my-first-repo</div>\n+      <form action=\"/repositories/delete\" method=\"post\" style=\"margin-top:1.5rem;\">\n+        <input type=\"hidden\" name=\"id\" value=\"1\">\n+        <div class=\"actions\">\n+          <button type=\"submit\"><i class=\"fa-solid fa-trash\"></i> Yes, delete</button>\n+          <a href=\"repositories.html\" class=\"cancel\"><i class=\"fa-solid fa-ban\"></i> Cancel</a>\n+        </div>\n+      </form>\n+    </div>\n+  </div>\n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nnew file mode 100644\nindex 0000000..a160736\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -0,0 +1,106 @@\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-database\"></i> Repositories</h1>{% endblock %}\n+\n+{% block main %}\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <title>Repositories - List</title>\n+  <style>\n+    .actions {\n+      display: flex;\n+      gap: 0.5rem;\n+      justify-content: center;\n+      flex-wrap: wrap;\n+    }\n+    .repo-list {\n+      display: flex;\n+      flex-direction: column;\n+      gap: 1rem;\n+    }\n+    .repo-row {\n+      display: flex;\n+      align-items: center;\n+      justify-content: space-between;\n+      padding: 1rem;\n+      border-radius: 8px;\n+      flex-wrap: wrap;\n+    }\n+    .repo-info {\n+      display: flex;\n+      align-items: center;\n+      gap: 1rem;\n+      flex: 1;\n+      min-width: 220px;\n+    }\n+    .repo-name {\n+      font-size: 1.1rem;\n+      font-weight: 600;\n+    }\n+    @media (max-width: 600px) {\n+      .repo-row { flex-direction: column; align-items: stretch; }\n+      .actions { justify-content: flex-start; }\n+    }\n+    .topbar {\n+      display: flex;\n+      margin-bottom: 1rem;\n+    }\n+    button, a.button {\n+      padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;\n+      transition: background 0.2s;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+      \n+    <div class=\"topbar\">\n+      <a class=\"button create\" href=\"/settings/repositories/create.html\">\n+        <i class=\"fa-solid fa-plus\"></i> New Repository\n+      </a>\n+    </div>\n+    <section class=\"repo-list\">\n+      <!-- Example repository entries; replace with your templating/iteration -->\n+      {% for repo in repositories %}\n+      <div class=\"repo-row\">\n+        <div class=\"repo-info\">\n+            <span class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repo.name }}</span>\n+        \n+<span title=\"Public\">\n+    <i class=\"fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}\"></i>\n+    {% if repo.is_private %}Private{% else %}Public{% endif %}\n+</span>\n+        </div>\n+        <div class=\"actions\">\n+            <a class=\"button browse\" href=\"/repositories/{{ user.username }}/{{ repo.name }}\" target=\"_blank\">\n+            <i class=\"fa-solid fa-folder-open\"></i> Browse\n+          </a>\n+          <a class=\"button clone\" href=\"/repositories/{{ user.username }}/{{ repo.name }}/clone\">\n+            <i class=\"fa-solid fa-code-branch\"></i> Clone\n+          </a>\n+          <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\n+            <i class=\"fa-solid fa-pen\"></i> Edit\n+          </a>\n+          <a class=\"button delete\" href=\"/settings/repositories/{{ repo.name }}/delete.html\">\n+            <i class=\"fa-solid fa-trash\"></i> Delete\n+          </a>\n+        </div>\n+      </div>\n+      {% endfor %}\n+      <!-- ... -->\n+    </section>\n+  </div>\n+</body>\n+</html>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/update.html b/src/snek/templates/settings/repositories/update.html\nnew file mode 100644\nindex 0000000..5168c92\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/update.html\n@@ -0,0 +1,45 @@\n+{% extends \"settings/index.html\" %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-pen\"></i> Update Repository</h1>{% endblock %}\n+\n+{% block main %}\n+  <style>\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    button {\n+      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n+      cursor: pointer;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .cancel {\n+    }\n+      @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+    }\n+  </style>\n+  <div class=\"container\">\n+    <form method=\"post\">\n+      <!-- Assume hidden id for backend use -->\n+      <input type=\"hidden\" name=\"id\" value=\"{{ repository.id }}\">\n+      <div>\n+        <label for=\"name\"><i class=\"fa-solid fa-book\"></i> Name</label>\n+        <input type=\"text\" id=\"name\" name=\"name\" value=\"{{ repository.name }}\" readonly>\n+      </div>\n+      <div>\n+        <label>\n+            <input type=\"checkbox\" name=\"is_private\" value=\"1\" {% if repository.is_private %}checked{% endif %}>\n+            <i class=\"fa-solid fa-lock\"></i> Private\n+        </label>\n+      </div>\n+      <button type=\"submit\"><i class=\"fa-solid fa-pen\"></i> Update</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i>Cancel</button>\n+    </form>\n+  </div>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/sidebar.html b/src/snek/templates/settings/sidebar.html\nindex f9533be..9673b6e 100644\n--- a/src/snek/templates/settings/sidebar.html\n+++ b/src/snek/templates/settings/sidebar.html\n@@ -3,7 +3,7 @@\n     <h2>You</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/settings/profile.html\">Profile</a></li>\n-        <li><a class=\"no-select\" href=\"/settings/gists.html\">Gists</a></li>\n+        <li><a class=\"no-select\" href=\"/settings/repositories/index.html\">Repositories</a></li>\n     </ul>\n \n   </aside>\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nnew file mode 100644\nindex 0000000..1b652e6\n--- /dev/null\n+++ b/src/snek/view/settings/repositories.py\n@@ -0,0 +1,68 @@\n+import asyncio\n+from aiohttp import web\n+\n+from snek.system.view import BaseFormView\n+import pathlib\n+\n+class RepositoriesIndexView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        user_uid = self.session.get(\"uid\")\n+        \n+        repositories = []\n+        async for repository in self.services.repository.find(user_uid=user_uid):\n+            repositories.append(repository.record)\n+\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories})\n+\n+\n+\n+\n+class RepositoriesCreateView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/create.html\")\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+class RepositoriesUpdateView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        repository['is_private'] = int(data.get('is_private',0))\n+        await self.services.repository.save(repository)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+class RepositoriesDeleteView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/delete.html\")\n+\n+\n+"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added basic Git repository management functionality", "commit": "a5aac9a33701e3d4852fba13520771e6de82aac0", "diff": "commit a5aac9a33701e3d4852fba13520771e6de82aac0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 07:55:08 2025 +0200\n\n    Patch\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cce4bd7..1a5ac0c 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -31,7 +31,8 @@ dependencies = [\n     \"emoji\",\n     \"aiofiles\",\n     \"PyJWT\",\n-    \"multiavatar\"\n+    \"multiavatar\",\n+    \"gitpython\",\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0b5e14b..7e5e2c4 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -52,6 +52,7 @@ from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n+from snek.sgit import GitApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -184,12 +185,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n         self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n+        self.git = GitApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n-\n-        self.add_subapp(\n-            \"/docs\",\n-            DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n-        )\n+        self.add_subapp(\"/git\",self.git)\n         \n     \ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex f7694d7..93bba77 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -7,7 +7,7 @@ class RepositoryService(BaseService):\n     async def exists(self, user_uid, name, **kwargs):\n         kwargs[\"user_uid\"] = user_uid\n         kwargs[\"name\"] = name\n-        return await self.exists(**kwargs)\n+        return await super().exists(**kwargs)\n \n     async def init(self, user_uid, name):\n         repository_path = await self.services.user.get_repository_path(user_uid)\n@@ -21,16 +21,14 @@ class RepositoryService(BaseService):\n             stderr=asyncio.subprocess.PIPE\n         )\n         stdout, stderr = await process.communicate()\n-        if process.returncode == 0:\n-            print(f\"Bare Git repository created at: {repo_path}\")\n-        else:\n-            print(f\"Error creating repository: {stderr.decode().strip()}\")\n+        return process.returncode == 0\n \n     async def create(self, user_uid, name,is_private=False):\n         if await self.exists(user_uid=user_uid, name=name):\n             return False \n \n-\n+        if not await self.init(user_uid=user_uid, name=name):\n+            return False\n \n         model = await self.new()\n         model[\"user_uid\"] = user_uid\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 7ca0711..7b727f7 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -43,10 +43,7 @@ class UserService(BaseService):\n         return self.mapper.get_admin_uids()\n \n     async def get_repository_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-        if not path.exists():\n-            return None\n-        return path\n+        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n \n     async def get_static_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nnew file mode 100644\nindex 0000000..6955288\n--- /dev/null\n+++ b/src/snek/sgit.py\n@@ -0,0 +1,472 @@\n+import os\n+import aiohttp\n+from aiohttp import web\n+import git\n+import shutil\n+import json\n+import tempfile\n+import asyncio\n+import logging\n+import base64\n+import pathlib\n+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger = logging.getLogger('git_server')\n+\n+class GitApplication(web.Application):\n+    def __init__(self, parent=None):\n+        self.parent = parent\n+        super().__init__(client_max_size=100*1024*1024)\n+        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n+        self.USERS = {\n+            'x': 'x',\n+            'bob': 'bobpass',\n+        }\n+        self.add_routes([\n+            web.post('/create/{repo_name}', self.create_repository),\n+            web.delete('/delete/{repo_name}', self.delete_repository),\n+            web.get('/clone/{repo_name}', self.clone_repository),\n+            web.post('/push/{repo_name}', self.push_repository),\n+            web.post('/pull/{repo_name}', self.pull_repository),\n+            web.get('/status/{repo_name}', self.status_repository),\n+            web.get('/list', self.list_repositories),\n+            web.get('/branches/{repo_name}', self.list_branches),\n+            web.post('/branches/{repo_name}', self.create_branch),\n+            web.get('/log/{repo_name}', self.commit_log),\n+            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n+            web.get('/{path:.+}/info/refs', self.git_smart_http),\n+            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n+            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n+            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n+        ])\n+\n+    async def check_basic_auth(self, request):\n+        return \"retoor\", pathlib.Path(self.REPO_DIR)\n+\n+    @staticmethod\n+    def require_auth(handler):\n+        async def wrapped(self, request, *args, **kwargs):\n+            username, repository_path = await self.check_basic_auth(request)\n+            if not username or not repository_path:\n+                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n+            request['username'] = username\n+            request['repository_path'] = repository_path\n+            return await handler(self, request, *args, **kwargs)\n+        return wrapped\n+\n+    def repo_path(self, repository_path, repo_name):\n+        return repository_path.joinpath(repo_name + '.git')\n+\n+    def check_repo_exists(self, repository_path, repo_name):\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if not os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository not found\", status=404)\n+        return None\n+\n+    @require_auth\n+    async def create_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        if not repo_name or '/' in repo_name or '..' in repo_name:\n+            return web.Response(text=\"Invalid repository name\", status=400)\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository already exists\", status=400)\n+        try:\n+            git.Repo.init(repo_dir, bare=True)\n+            logger.info(f\"Created repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Created repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def delete_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            shutil.rmtree(self.repo_path(repository_path, repo_name))\n+            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Deleted repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def clone_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        host = request.host\n+        response_data = {\n+            \"repository\": repo_name,\n+            \"clone_command\": f\"git clone {clone_url}\",\n+            \"clone_url\": clone_url\n+        }\n+        return web.json_response(response_data)\n+\n+    @require_auth\n+    async def push_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        commit_message = data.get('commit_message', 'Update from server')\n+        branch = data.get('branch', 'main')\n+        changes = data.get('changes', [])\n+        if not changes:\n+            return web.Response(text=\"No changes provided\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            for change in changes:\n+                file_path = os.path.join(temp_dir, change.get('file', ''))\n+                content = change.get('content', '')\n+                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n+                with open(file_path, 'w') as f:\n+                    f.write(content)\n+            temp_repo.git.add(A=True)\n+            if not temp_repo.config_reader().has_section('user'):\n+                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n+                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n+            temp_repo.index.commit(commit_message)\n+            origin = temp_repo.remote('origin')\n+            origin.push(refspec=f\"{branch}:{branch}\")\n+        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n+        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n+\n+    @require_auth\n+    async def pull_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            data = {}\n+        remote_url = data.get('remote_url')\n+        branch = data.get('branch', 'main')\n+        if not remote_url:\n+            return web.Response(text=\"Remote URL is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                remote_name = \"pull_source\"\n+                try:\n+                    remote = local_repo.create_remote(remote_name, remote_url)\n+                except git.GitCommandError:\n+                    remote = local_repo.remote(remote_name)\n+                    remote.set_url(remote_url)\n+                remote.fetch()\n+                local_repo.git.merge(f\"{remote_name}/{branch}\")\n+                origin = local_repo.remote('origin')\n+                origin.push()\n+                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n+                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def status_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                branches = [b.name for b in temp_repo.branches]\n+                active_branch = temp_repo.active_branch.name\n+                commits = []\n+                for commit in list(temp_repo.iter_commits(max_count=5)):\n+                    commits.append({\n+                        \"id\": commit.hexsha,\n+                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                        \"date\": commit.committed_datetime.isoformat(),\n+                        \"message\": commit.message\n+                    })\n+                files = []\n+                for root, dirs, filenames in os.walk(temp_dir):\n+                    if '.git' in root:\n+                        continue\n+                    for filename in filenames:\n+                        full_path = os.path.join(root, filename)\n+                        rel_path = os.path.relpath(full_path, temp_dir)\n+                        files.append(rel_path)\n+                status_info = {\n+                    \"repository\": repo_name,\n+                    \"branches\": branches,\n+                    \"active_branch\": active_branch,\n+                    \"recent_commits\": commits,\n+                    \"files\": files\n+                }\n+                return web.json_response(status_info)\n+            except Exception as e:\n+                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_repositories(self, request):\n+        username = request['username']\n+        try:\n+            repos = []\n+            user_dir = self.REPO_DIR\n+            if os.path.exists(user_dir):\n+                for item in os.listdir(user_dir):\n+                    item_path = os.path.join(user_dir, item)\n+                    if os.path.isdir(item_path) and item.endswith('.git'):\n+                        repos.append(item[:-4])\n+            if request.query.get('format') == 'json':\n+                return web.json_response({\"repositories\": repos})\n+            else:\n+                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n+        except Exception as e:\n+            logger.error(f\"Error listing repositories: {str(e)}\")\n+            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_branches(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            branches = [b.name for b in temp_repo.branches]\n+            return web.json_response({\"branches\": branches})\n+\n+    @require_auth\n+    async def create_branch(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        branch_name = data.get('branch_name')\n+        start_point = data.get('start_point', 'HEAD')\n+        if not branch_name:\n+            return web.Response(text=\"Branch name is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                temp_repo.git.branch(branch_name, start_point)\n+                temp_repo.git.push('origin', branch_name)\n+                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n+                return web.Response(text=f\"Created branch {branch_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def commit_log(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            limit = int(request.query.get('limit', 10))\n+            branch = request.query.get('branch', 'main')\n+        except ValueError:\n+            return web.Response(text=\"Invalid limit parameter\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                commits = []\n+                try:\n+                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n+                        commits.append({\n+                            \"id\": commit.hexsha,\n+                            \"short_id\": commit.hexsha[:7],\n+                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                            \"date\": commit.committed_datetime.isoformat(),\n+                            \"message\": commit.message.strip()\n+                        })\n+                except git.GitCommandError as e:\n+                    if \"unknown revision or path\" in str(e):\n+                        commits = []\n+                    else:\n+                        raise\n+                return web.json_response({\n+                    \"repository\": repo_name,\n+                    \"branch\": branch,\n+                    \"commits\": commits\n+                })\n+            except Exception as e:\n+                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def file_content(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        file_path = request.match_info.get('file_path', '')\n+        branch = request.query.get('branch', 'main')\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                try:\n+                    temp_repo.git.checkout(branch)\n+                except git.GitCommandError:\n+                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n+                file_full_path = os.path.join(temp_dir, file_path)\n+                if not os.path.exists(file_full_path):\n+                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n+                if os.path.isdir(file_full_path):\n+                    files = os.listdir(file_full_path)\n+                    return web.json_response({\n+                        \"repository\": repo_name,\n+                        \"path\": file_path,\n+                        \"type\": \"directory\",\n+                        \"contents\": files\n+                    })\n+                else:\n+                    try:\n+                        with open(file_full_path, 'r') as f:\n+                            content = f.read()\n+                        return web.Response(text=content)\n+                    except UnicodeDecodeError:\n+                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n+            except Exception as e:\n+                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def git_smart_http(self, request):\n+        username = request['username']\n+        repository_path = request['repository_path']\n+        path = request.path\n+        async def get_repository_path():\n+            req_path = path.lstrip('/')\n+            if req_path.endswith('/info/refs'):\n+                repo_name = req_path[:-len('/info/refs')]\n+            elif req_path.endswith('/git-upload-pack'):\n+                repo_name = req_path[:-len('/git-upload-pack')]\n+            elif req_path.endswith('/git-receive-pack'):\n+                repo_name = req_path[:-len('/git-receive-pack')]\n+            else:\n+                repo_name = req_path\n+            if repo_name.endswith('.git'):\n+                repo_name = repo_name[:-4]\n+            repo_name = repo_name.lstrip('git/')\n+            repo_dir = repository_path.joinpath(repo_name + '.git')\n+            logger.info(f\"Resolved repo path: {repo_dir}\")\n+            return repo_dir\n+        async def handle_info_refs(service):\n+            repo_path = await get_repository_path()\n+        \n+            logger.info(f\"handle_info_refs: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate()\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason='OK',\n+                    headers={\n+                        'Content-Type': f'application/x-{service}-advertisement',\n+                        'Cache-Control': 'no-cache'\n+                    }\n+                )\n+                await response.prepare(request)\n+                length = len(packet) + 4\n+                header = f\"{length:04x}\"\n+                await response.write(f\"{header}{packet}0000\".encode())\n+                await response.write(stdout)\n+                return response\n+            except Exception as e:\n+                logger.error(f\"Error handling info/refs: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        async def handle_service_rpc(service):\n+            repo_path = await get_repository_path()\n+            logger.info(f\"handle_service_rpc: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n+                return web.Response(text=\"Invalid Content-Type\", status=403)\n+            body = await request.read()\n+            cmd = [service, '--stateless-rpc', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdin=asyncio.subprocess.PIPE,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate(input=body)\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                return web.Response(\n+                    body=stdout,\n+                    content_type=f'application/x-{service}-result'\n+                )\n+            except Exception as e:\n+                logger.error(f\"Error handling service RPC: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        if request.method == 'GET' and path.endswith('/info/refs'):\n+            service = request.query.get('service')\n+            if service in ('git-upload-pack', 'git-receive-pack'):\n+                return await handle_info_refs(service)\n+            else:\n+                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n+        elif request.method == 'POST' and '/git-upload-pack' in path:\n+            return await handle_service_rpc('git-upload-pack')\n+        elif request.method == 'POST' and '/git-receive-pack' in path:\n+            return await handle_service_rpc('git-receive-pack')\n+        return web.Response(text=\"Not found\", status=404)\n+\n+if __name__ == '__main__':\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+        logger.info(\"Using uvloop for improved performance\")\n+    except ImportError:\n+        logger.info(\"uvloop not available, using standard event loop\")\n+    app = GitApplication()\n+    logger.info(\"Starting Git server on port 8080\")\n+    web.run_app(app, port=8080)"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Corrected repository deletion endpoint and added repository deletion functionality.", "commit": "e06776d81d0fa40e4d9d5f57a6259df8db271372", "diff": "commit e06776d81d0fa40e4d9d5f57a6259df8db271372\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 08:24:43 2025 +0200\n\n    Performance.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7e5e2c4..83dcc03 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -183,7 +183,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n-        self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n         self.git = GitApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex 93bba77..120c232 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,9 +1,21 @@\n from snek.system.service import BaseService\n import asyncio \n+import shutil\n \n class RepositoryService(BaseService):\n     mapper_name = \"repository\"\n \n+    async def delete(self, user_uid, name):\n+        loop = asyncio.get_event_loop()\n+        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n+        try:\n+            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n+        except Exception as ex:\n+            print(ex)\n+\n+        await super().delete(user_uid=user_uid, name=name)\n+\n+\n     async def exists(self, user_uid, name, **kwargs):\n         kwargs[\"user_uid\"] = user_uid\n         kwargs[\"name\"] = name\n@@ -14,6 +26,9 @@ class RepositoryService(BaseService):\n         if not repository_path.exists():\n             repository_path.mkdir(parents=True)\n         repository_path = repository_path.joinpath(name)\n+        repository_path = str(repository_path)\n+        if not repository_path.endswith(\".git\"):\n+            repository_path += \".git\"\n         command = ['git', 'init', '--bare', repository_path]\n         process = await asyncio.subprocess.create_subprocess_exec(\n             *command,\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 3b6c7c6..4a59024 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -64,7 +64,7 @@ class BaseMapper:\n         for record in self.db.query(sql, *args):\n             yield dict(record)\n \n-    async def delete(self, kwargs=None) -> int:\n+    async def delete(self, **kwargs) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\n         return self.table.delete(**kwargs)\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nindex af2a906..5ba6c5b 100644\n--- a/src/snek/templates/settings/repositories/delete.html\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -1,20 +1,10 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <title>Delete Repository</title>\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>{% endblock %}\n+\n+{% block main %}\n   <style>\n-    body { font-family: sans-serif; margin: 2rem; }\n-    .container { max-width: 400px; margin: 0 auto; }\n-    .confirm-box {\n-      padding: 2rem;\n-      border-radius: 10px;\n-      text-align: center;\n-      margin-top: 2rem;\n-    }\n     .repo-name {\n       font-weight: bold;\n       font-size: 1.2rem;\n@@ -22,9 +12,9 @@\n     }\n     .actions {\n-      display: flex; gap: 1rem; justify-content: center; margin-top: 1.5rem;\n+      display: flex; gap: 1rem; justify-content: left; margin-top: 1.5rem;\n     }\n-    button, a {\n+    button {\n       border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n       font-size: 1rem; cursor: pointer;\n@@ -39,25 +29,15 @@\n       .confirm-box { padding: 1rem; }\n     }\n   </style>\n-</head>\n-<body>\n   <div class=\"container\">\n-    <h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>\n-    <div class=\"confirm-box\">\n-      <div>\n-      </div>\n       <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n-      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> my-first-repo</div>\n-      <form action=\"/repositories/delete\" method=\"post\" style=\"margin-top:1.5rem;\">\n-        <input type=\"hidden\" name=\"id\" value=\"1\">\n+      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repository.name }}</div>\n+      <form method=\"post\" style=\"margin-top:1.5rem;\">\n+        <input type=\"hidden\" name=\"name\" value=\"{{ repository.name }}\">\n         <div class=\"actions\">\n           <button type=\"submit\"><i class=\"fa-solid fa-trash\"></i> Yes, delete</button>\n-          <a href=\"repositories.html\" class=\"cancel\"><i class=\"fa-solid fa-ban\"></i> Cancel</a>\n+          <button type=\"button\" onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Cancel</button>\n         </div>\n       </form>\n-    </div>\n   </div>\n-</body>\n-</html>\n-\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nindex a160736..a8d7c2f 100644\n--- a/src/snek/templates/settings/repositories/index.html\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -92,7 +92,7 @@\n           <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\n             <i class=\"fa-solid fa-pen\"></i> Edit\n           </a>\n-          <a class=\"button delete\" href=\"/settings/repositories/{{ repo.name }}/delete.html\">\n+          <a class=\"button delete\" href=\"/settings/repositories/repository/{{ repo.name }}/delete.html\">\n             <i class=\"fa-solid fa-trash\"></i> Delete\n           </a>\n         </div>\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 1b652e6..0c25c1d 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -61,8 +61,24 @@ class RepositoriesDeleteView(BaseFormView):\n     login_required = True\n \n     async def get(self):\n-        \n-        return await self.render_template(\"settings/repositories/delete.html\")\n+            \n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n \n+    async def post(self):\n+        user_uid = self.session.get(\"uid\")\n+        name = self.request.match_info[\"name\"]\n+        repository = await self.services.repository.get(\n+                user_uid=user_uid, name=name\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        await self.services.repository.delete(user_uid=user_uid, name=name)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Add uvloop dependency and fix repo path resolution", "commit": "adb59eff68e5c855fbce6f930db1ea13f59683f6", "diff": "commit adb59eff68e5c855fbce6f930db1ea13f59683f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 08:54:33 2025 +0200\n\n    Do it.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 1a5ac0c..b6f1688 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,6 +33,7 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n+    \"uvloop\"\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 6955288..74dc884 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -382,10 +382,10 @@ class GitApplication(web.Application):\n                 repo_name = req_path\n             if repo_name.endswith('.git'):\n                 repo_name = repo_name[:-4]\n-            repo_name = repo_name.lstrip('git/')\n-            repo_dir = repository_path.joinpath(repo_name + '.git')\n+            repo_name = repo_name[4:]\n+            repo_dir = repository_path.joinpath(repo_name + \".git\")\n             logger.info(f\"Resolved repo path: {repo_dir}\")\n-            return repo_dir\n+            return repo_dir \n         async def handle_info_refs(service):\n             repo_path = await get_repository_path()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement basic authentication for git receive-pack", "commit": "3ae30f1f7645203a8e8c15bd298d802fffbd2334", "diff": "commit 3ae30f1f7645203a8e8c15bd298d802fffbd2334\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:09:33 2025 +0200\n\n    Do it.\n\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 74dc884..64bad65 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -41,9 +41,25 @@ class GitApplication(web.Application):\n             web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n         ])\n \n+\n     async def check_basic_auth(self, request):\n-        return \"retoor\", pathlib.Path(self.REPO_DIR)\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return None,None\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.parent.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        if not request[\"user\"]:\n+            return None,None\n+        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n+            request[\"user\"][\"uid\"]\n+        )\n+\n+        return request[\"user\"]['username'],request[\"repository_path\"]\n+\n \n     @staticmethod\n     def require_auth(handler):"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Increase client max size for uploads", "commit": "e5d155e1249f9df7c504a95c98171f7e4fe5d5a4", "diff": "commit e5d155e1249f9df7c504a95c98171f7e4fe5d5a4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:18:55 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 83dcc03..e79b511 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -82,7 +82,7 @@ class Application(BaseApplication):\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n         self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n         super().__init__(\n-            middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n+            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self.tasks = asyncio.Queue()\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 64bad65..b7ccfe9 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -15,7 +15,7 @@ logger = logging.getLogger('git_server')\n class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n-        super().__init__(client_max_size=100*1024*1024)\n+        super().__init__(client_max_size=1024*1024*1024*5)\n         self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n         self.USERS = {\n             'x': 'x',"}
{"repo": ".", "date": "2025-05-09", "line": "refactor: Migrate from argparse to click and simplify application startup", "commit": "7e8ae1632d19238954ca96657da1d3950ebd413c", "diff": "commit 7e8ae1632d19238954ca96657da1d3950ebd413c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:56:23 2025 +0200\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex 7a725b4..852efd4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -5,17 +5,21 @@ GUNICORN=./.venv/bin/gunicorn\n GUNICORN_WORKERS = 1\n PORT = 8081\n \n-python:\n-\t$(PYTHON)\n+\n+\n+shell:\n+\t.venv/bin/snek shell\n \n dump:\n \t@$(PYTHON) -m snek.dump\n \n build:\n \n+serve: run \n+\n \n run:\n-\t.venv/usr/bin/snek\n+\t.venv/bin/snek serve\n \t\n install: ubuntu\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 198ea1e..35e56e3 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,40 +1,32 @@\n-import argparse\n+import click\n import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n+from IPython import start_ipython\n \n+@click.group()\n+def cli():\n+    pass\n \n-def main():\n-    \n+@cli.command()\n+@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n+@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def serve(port, host, db_path):\n     asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    \n-    parser = argparse.ArgumentParser(description=\"Run the web application.\")\n-    parser.add_argument(\n-        \"--port\",\n-        type=int,\n-        default=8081,\n-        help=\"Port to run the application on (default: 8081)\",\n-    )\n-    parser.add_argument(\n-        \"--host\",\n-        type=str,\n-        default=\"0.0.0.0\",\n-        help=\"Host to run the application on (default: 0.0.0.0)\",\n-    )\n-    parser.add_argument(\n-        \"--db_path\",\n-        type=str,\n-        default=\"snek.db\",\n-    )\n-\n-    args = parser.parse_args()\n-\n     web.run_app(\n     )\n \n+@cli.command()\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def shell(db_path):\n+    start_ipython(argv=[], user_ns={'app': app})\n+\n+def main():\n+    cli()\n \n if __name__ == \"__main__\":\n     main()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository view and related functionality", "commit": "95ad49df432195cb127f9fe695eac14678422b37", "diff": "commit 95ad49df432195cb127f9fe695eac14678422b37\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:08:46 2025 +0200\n\n    progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e79b511..ceb7c9d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,6 +38,7 @@ from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n+from snek.view.repository import RepositoryView\n from snek.view.search_user import SearchUserView\n from snek.view.settings.repositories import RepositoriesIndexView\n from snek.view.settings.repositories import RepositoriesCreateView\n@@ -164,6 +165,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n         self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n@@ -180,6 +182,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 6e28f84..e2b55b4 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -9,6 +9,7 @@ class DriveItemModel(BaseModel):\n     path = ModelField(name=\"path\", required=True, kind=str)\n     file_type = ModelField(name=\"file_type\", required=True, kind=str)\n     file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n \n     @property\n     def extension(self):\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 7b727f7..76e6d1c 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -7,6 +7,9 @@ from snek.system.service import BaseService\n class UserService(BaseService):\n     mapper_name = \"user\"\n \n+    async def get_by_username(self, username):\n+        return await self.get(username=username)\n+\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex b7ccfe9..f8bfeb7 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -107,6 +107,7 @@ class GitApplication(web.Application):\n         error_response = self.check_repo_exists(repository_path, repo_name)\n         if error_response:\n             return error_response\n         try:\n             shutil.rmtree(self.repo_path(repository_path, repo_name))\n             logger.info(f\"Deleted repository: {repo_name} for user {username}\")\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex b05eb21..6b74792 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -15,8 +15,15 @@\n   <script src=\"/generic-form.js\" type=\"module\"></script>\n   <script src=\"/html-frame.js\" type=\"module\"></script>\n   <script src=\"/app.js\" type=\"module\"></script>\n+  <script src=\"/file-manager.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n-\n+<link\n+    rel=\"stylesheet\"\n+    integrity=\"sha512-pBMV+3tn6+5xAZuhI6tyCmQkXh15riZDqGPxAx/U+FuiI5Dh3ZTjM23cZqQ25jJCfi8+ka9gzC2ukNkGkP/Aw==\"\n+    crossorigin=\"anonymous\"\n+    referrerpolicy=\"no-referrer\"\n+  />\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 4aad6eb..4f4b036 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -5,7 +5,6 @@\n \n {% block main %}\n-\n <section class=\"chat-area\">\n     <div class=\"chat-header\">\n         <h2>Search user</h2>\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nindex a8d7c2f..115259e 100644\n--- a/src/snek/templates/settings/repositories/index.html\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -83,10 +83,10 @@\n </span>\n         </div>\n         <div class=\"actions\">\n-            <a class=\"button browse\" href=\"/repositories/{{ user.username }}/{{ repo.name }}\" target=\"_blank\">\n+            <a class=\"button browse\" href=\"/repository/{{ user.username.value }}/{{ repo.name }}\" target=\"_blank\">\n             <i class=\"fa-solid fa-folder-open\"></i> Browse\n           </a>\n-          <a class=\"button clone\" href=\"/repositories/{{ user.username }}/{{ repo.name }}/clone\">\n+          <a class=\"button clone\" href=\"/git/{{ user.uid.value }}/{{ repo.name.value }}\">\n             <i class=\"fa-solid fa-code-branch\"></i> Clone\n           </a>\n           <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 853cdb2..e3c3343 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -3,18 +3,250 @@ from aiohttp import web\n from snek.system.view import BaseView\n \n \n+import os\n+import mimetypes\n+from aiohttp import web\n+from urllib.parse import unquote, quote\n+from datetime import datetime\n+\n+\n+\n+\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n+\"\"\"\n+from aiohttp import web\n+from pathlib import Path\n+import mimetypes, urllib.parse\n+\n+BASE_DIR   = Path(__file__).parent.resolve()\n+ROOT_DIR.mkdir(exist_ok=True)\n+ASSETS_DIR.mkdir(exist_ok=True)\n+\n+\n+def safe_resolve_path(rel: str) -> Path:\n+    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n+    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n+    if target == ROOT_DIR or ROOT_DIR in target.parents:\n+        return target\n+    raise FileNotFoundError(\"Unsafe path\")\n+\n+\n class DriveView(BaseView):\n+    async def get(self):\n+        rel = self.request.query.get(\"path\", \"\")\n+        offset = int(self.request.query.get(\"offset\", 0))\n+        limit  = int(self.request.query.get(\"limit\", 20))\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        if rel:\n+            target.joinpath(rel)\n+\n+        if not target.exists():\n+            return web.json_response({\"error\": \"Not found\"}, status=404)\n+\n+        if target.is_dir():\n+            entries = []\n+            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n+                item_path = (Path(rel) / p.name).as_posix()\n+                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n+                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n+                        if p.is_file() else None)\n+                entries.append({\n+                    \"name\": p.name,\n+                    \"type\": \"directory\" if p.is_dir() else \"file\",\n+                    \"mimetype\": mime,\n+                    \"size\": p.stat().st_size if p.is_file() else None,\n+                    \"path\": item_path,\n+                    \"url\": url,\n+                })\n+            import json \n+            total = len(entries)\n+            items = entries[offset:offset+limit]\n+            return web.json_response({\n+                \"items\": json.loads(json.dumps(items,default=str)),\n+                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n+            })\n+        \n+        with open(target, \"rb\") as f:\n+            content = f.read()\n+            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n+        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n+        return web.json_response({\n+            \"name\": target.name,\n+            \"type\": \"file\",\n+            \"mimetype\": mimetypes.guess_type(target.name)[0],\n+            \"size\": target.stat().st_size,\n+            \"path\": rel,\n+            \"url\": str(url),\n+        })\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+class DriveView222(BaseView):\n+    PAGE_SIZE = 20\n+\n+    async def base_path(self):\n+        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n+    async def get_full_path(self, rel_path):\n+        base_path = await self.base_path()\n+        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n+        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n+        if not full_path.startswith(os.path.abspath(base_path)):\n+            raise web.HTTPForbidden(reason=\"Invalid path\")\n+        return full_path\n+\n+    async def make_absolute_url(self, rel_path):\n+        rel_path = rel_path.lstrip(\"/\")\n+        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n+        return url\n+\n+    async def entry_details(self, dir_path, entry, parent_rel_path):\n+        entry_path = os.path.join(dir_path, entry)\n+        stat = os.stat(entry_path)\n+        is_dir = os.path.isdir(entry_path)\n+        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n+        size = stat.st_size if not is_dir else None\n+        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n+        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n+        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n+        return {\n+            \"name\": entry,\n+            \"type\": \"dir\" if is_dir else \"file\",\n+            \"mimetype\": mimetype,\n+            \"size\": size,\n+            \"created_at\": created_at,\n+            \"updated_at\": updated_at,\n+            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n+        }\n+\n+    async def get(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        page = int(self.request.query.get(\"page\", 1))\n+        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n+        abs_url = await self.make_absolute_url(rel_path)\n+\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+\n+        if os.path.isdir(full_path):\n+            entries = os.listdir(full_path)\n+            entries.sort()\n+            start = (page - 1) * page_size\n+            end = start + page_size\n+            paged_entries = entries[start:end]\n+            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n+            return web.json_response({\n+                \"path\": rel_path,\n+                \"absolute_url\": abs_url,\n+                \"entries\": details,\n+                \"total\": len(entries),\n+                \"page\": page,\n+                \"page_size\": page_size,\n+            })\n+        else:\n+            with open(full_path, \"rb\") as f:\n+                content = f.read()\n+            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n+            headers = {\"X-Absolute-Url\": abs_url}\n+            return web.Response(body=content, content_type=mimetype, headers=headers)\n+\n+    async def post(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if os.path.exists(full_path):\n+            raise web.HTTPConflict(reason=\"File or directory already exists\")\n+        data = await self.request.post()\n+        if data.get(\"type\") == \"dir\":\n+            os.makedirs(full_path)\n+            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            file_field = data.get(\"file\")\n+            if not file_field:\n+                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n+            with open(full_path, \"wb\") as f:\n+                f.write(file_field.file.read())\n+            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+    async def put(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"File not found\")\n+        if os.path.isdir(full_path):\n+            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n+        body = await self.request.read()\n+        with open(full_path, \"wb\") as f:\n+            f.write(body)\n+        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n+\n+    async def delete(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+        if os.path.isdir(full_path):\n+            os.rmdir(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            os.remove(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+\n+class DriveViewi2(BaseView):\n \n     login_required = True\n \n     async def get(self):\n \n         drive_uid = self.request.match_info.get(\"drive\")\n+            \n+\n+        before = self.request.query.get(\"before\")\n+        filters = {} \n+        if before:\n+            filters[\"created_at__lt\"] = before\n \n         if drive_uid:\n+            filters['drive_uid'] = drive_uid \n             drive = await self.services.drive.get(uid=drive_uid)\n             drive_items = []\n-            async for item in drive.items:\n+            \n+            \n+            \n+            async for item in self.services.drive_item.find(**filters):\n                 record = item.record\n                 record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n                 drive_items.append(record)\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 0c25c1d..093d229 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -15,8 +15,10 @@ class RepositoriesIndexView(BaseFormView):\n         repositories = []\n         async for repository in self.services.repository.find(user_uid=user_uid):\n             repositories.append(repository.record)\n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n-        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories})\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})"}
{"repo": ".", "date": "2025-05-09", "line": "'NoneType' object is not subscriptable", "commit": "17c6124a57a394c63427a0038e598fdb40560f15", "diff": "commit 17c6124a57a394c63427a0038e598fdb40560f15\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:19:29 2025 +0200\n\n    Minify.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex 8b13789..e69de29 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -1 +0,0 @@\n-\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 35e56e3..5d861d9 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,32 +1,21 @@\n-import click\n-import uvloop\n+_D='Database path for the application'\n+_C='snek.db'\n+_B='--db_path'\n+_A=True\n+import click,uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n-\n @click.group()\n-def cli():\n-    pass\n-\n+def cli():0\n @cli.command()\n-@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n-@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n-@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n-def serve(port, host, db_path):\n-    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    web.run_app(\n-    )\n-\n+@click.option('--port',default=8081,show_default=_A,help='Port to run the application on')\n+@click.option('--host',default='0.0.0.0',show_default=_A,help='Host to run the application on')\n+@click.option(_B,default=_C,show_default=_A,help=_D)\n @cli.command()\n-@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n-def shell(db_path):\n-    start_ipython(argv=[], user_ns={'app': app})\n-\n-def main():\n-    cli()\n-\n-if __name__ == \"__main__\":\n-    main()\n+@click.option(_B,default=_C,show_default=_A,help=_D)\n+def main():cli()\n+if __name__=='__main__':main()\n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ceb7c9d..f5f1948 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,37 +1,31 @@\n-import asyncio\n-import logging\n-import pathlib\n-import time\n-import uuid\n-\n+_G='name'\n+_F='static'\n+_E='user'\n+_D=None\n+_C=True\n+_B='channel_uid'\n+_A='uid'\n+import asyncio,logging,pathlib,time,uuid\n from snek.view.threads import ThreadsView\n-\n logging.basicConfig(level=logging.DEBUG)\n-\n from concurrent.futures import ThreadPoolExecutor\n-\n from aiohttp import web\n-from aiohttp_session import (\n-    get_session as session_get,\n-    session_middleware,\n-    setup as session_setup,\n-)\n+from aiohttp_session import get_session as session_get,session_middleware,setup as session_setup\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n-\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import auth_middleware, cors_middleware\n+from snek.system.middleware import auth_middleware,cors_middleware\n from snek.system.profiler import profiler_handler\n-from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n-from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.system.template import EmojiExtension,LinkifyExtension,PythonExtension\n+from snek.view.about import AboutHTMLView,AboutMDView\n from snek.view.avatar import AvatarView\n-from snek.view.docs import DocsHTMLView, DocsMDView\n+from snek.view.docs import DocsHTMLView,DocsMDView\n from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n@@ -48,275 +42,79 @@ from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n from snek.view.status import StatusView\n-from snek.view.terminal import TerminalSocketView, TerminalView\n+from snek.view.terminal import TerminalSocketView,TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-\n-SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n-\n-\n+SESSION_KEY=b'c79a0c5fda4b424189c427d28c9f7c34'\n @web.middleware\n-async def session_middleware(request, handler):\n-    setattr(request, \"session\", await session_get(request))\n-    response = await handler(request)\n-    return response\n-\n-\n+async def session_middleware(request,handler):A=request;setattr(A,'session',await session_get(A));B=await handler(A);return B\n @web.middleware\n-async def trailing_slash_middleware(request, handler):\n-    if request.path and not request.path.endswith(\"/\"):\n-        raise web.HTTPFound(request.path + \"/\")\n-    return await handler(request)\n-\n-\n+async def trailing_slash_middleware(request,handler):\n+\tA=request\n+\tif A.path and not A.path.endswith('/'):raise web.HTTPFound(A.path+'/')\n+\treturn await handler(A)\n class Application(BaseApplication):\n-\n-    def __init__(self, *args, **kwargs):\n-        middlewares = [\n-            cors_middleware,\n-            web.normalize_path_middleware(merge_slashes=True),\n-        ]\n-        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n-        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n-        super().__init__(\n-            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n-        )\n-        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n-        self.tasks = asyncio.Queue()\n-        self._middlewares.append(session_middleware)\n-        self._middlewares.append(auth_middleware)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-        self.jinja2_env.add_extension(LinkifyExtension)\n-        self.jinja2_env.add_extension(PythonExtension)\n-        self.jinja2_env.add_extension(EmojiExtension)\n-\n-        self.setup_router()\n-        self.executor = None\n-        self.cache = Cache(self)\n-        self.services = get_services(app=self)\n-        self.mappers = get_mappers(app=self)\n-        self.on_startup.append(self.prepare_asyncio)\n-        self.on_startup.append(self.prepare_database)\n-\n-    async def prepare_asyncio(self, app):\n-        app.executor = ThreadPoolExecutor(max_workers=200)\n-        app.loop.set_default_executor(self.executor)\n-\n-    async def create_task(self, task):\n-        await self.tasks.put(task)\n-\n-    async def task_runner(self):\n-        while True:\n-            task = await self.tasks.get()\n-            self.db.begin()\n-            try:\n-                task_start = time.time()\n-                await task\n-                task_end = time.time()\n-                print(f\"Task {task} took {task_end - task_start} seconds\")\n-                self.tasks.task_done()\n-            except Exception as ex:\n-                print(ex)\n-            self.db.commit()\n-\n-    async def prepare_database(self, app):\n-        self.db.query(\"PRAGMA journal_mode=WAL\")\n-        self.db.query(\"PRAGMA syncnorm=off\")\n-\n-        try:\n-            if not self.db[\"user\"].has_index(\"username\"):\n-                self.db[\"user\"].create_index(\"username\", unique=True)\n-            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n-                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n-            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n-                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n-        except:\n-            pass\n-\n-        await app.services.drive.prepare_all()\n-        self.loop.create_task(self.task_runner())\n-\n-    def setup_router(self):\n-        self.router.add_get(\"/\", IndexView)\n-        self.router.add_static(\n-            \"/\",\n-            pathlib.Path(__file__).parent.joinpath(\"static\"),\n-            name=\"static\",\n-            show_index=True,\n-        )\n-        self.router.add_view(\"/profiler.html\", profiler_handler)\n-        self.router.add_view(\"/about.html\", AboutHTMLView)\n-        self.router.add_view(\"/about.md\", AboutMDView)\n-        self.router.add_view(\"/logout.json\", LogoutView)\n-        self.router.add_view(\"/logout.html\", LogoutView)\n-        self.router.add_view(\"/docs.html\", DocsHTMLView)\n-        self.router.add_view(\"/docs.md\", DocsMDView)\n-        self.router.add_view(\"/status.json\", StatusView)\n-        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n-        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n-        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n-        self.router.add_view(\"/web.html\", WebView)\n-        self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login.json\", LoginView)\n-        self.router.add_view(\"/register.html\", RegisterView)\n-        self.router.add_view(\"/register.json\", RegisterView)\n-        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n-        self.router.add_view(\"/drive.bin\", UploadView)\n-        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n-        self.router.add_view(\"/search-user.html\", SearchUserView)\n-        self.router.add_view(\"/search-user.json\", SearchUserView)\n-        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n-        self.router.add_get(\"/http-get\", self.handle_http_get)\n-        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-        self.router.add_get(\"/rpc.ws\", RPCView)\n-        self.router.add_view(\"/channel/{channel}.html\", WebView)\n-        self.router.add_view(\"/threads.html\", ThreadsView)\n-        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n-        self.router.add_view(\"/terminal.html\", TerminalView)\n-        self.router.add_view(\"/drive.json\", DriveView)\n-        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n-        self.router.add_view(\"/stats.json\", StatsView)\n-        self.router.add_view(\"/user/{user}.html\", UserView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n-        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n-        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n-        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n-        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n-        self.webdav = WebdavApplication(self)\n-        self.git = GitApplication(self)\n-        self.add_subapp(\"/webdav\", self.webdav)\n-        self.add_subapp(\"/git\",self.git)\n-        \n-    \n-    async def handle_test(self, request):\n-\n-        return await self.render_template(\n-            \"test.html\", request, context={\"name\": \"retoor\"}\n-        )\n-\n-    async def handle_http_get(self, request: web.Request):\n-        url = request.query.get(\"url\")\n-        content = await http.get(url)\n-        return web.Response(body=content)\n-\n-    async def handle_http_photo(self, request):\n-        url = request.query.get(\"url\")\n-        path = await http.create_site_photo(url)\n-        return web.Response(\n-            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n-        )\n-\n-    async def render_template(self, template, request, context=None):\n-        channels = []\n-        if not context:\n-            context = {}\n-        context[\"rid\"] = str(uuid.uuid4())\n-        if request.session.get(\"uid\"):\n-            async for subscribed_channel in self.services.channel_member.find(\n-                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n-            ):\n-                item = {}\n-                other_user = await self.services.channel_member.get_other_dm_user(\n-                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n-                )\n-                parent_object = await subscribed_channel.get_channel()\n-                last_message = await parent_object.get_last_message()\n-                color = None\n-                if last_message:\n-                    last_message_user = await last_message.get_user()\n-                    color = last_message_user[\"color\"]\n-                item[\"color\"] = color\n-                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n-                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n-                if other_user:\n-                    item[\"name\"] = other_user[\"nick\"]\n-                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                else:\n-                    item[\"name\"] = subscribed_channel[\"label\"]\n-                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n-\n-                channels.append(item)\n-\n-            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n-            if \"channels\" not in context:\n-                context[\"channels\"] = channels\n-            if \"user\" not in context:\n-                context[\"user\"] = await self.services.user.get(\n-                    request.session.get(\"uid\")\n-                )\n-\n-        self.template_path.joinpath(template)\n-\n-        await self.services.user.get_template_path(request.session.get(\"uid\"))\n-\n-        self.original_loader = self.jinja2_env.loader\n-\n-        self.jinja2_env.loader = await self.get_user_template_loader(\n-            request.session.get(\"uid\")\n-        )\n-\n-        rendered = await super().render_template(template, request, context)\n-\n-        self.jinja2_env.loader = self.original_loader\n-\n-        return rendered\n-\n-\n-    async def static_handler(self, request):\n-        file_name = request.match_info.get('filename', '')\n-\n-        paths = []\n-\n-        uid = request.session.get(\"uid\")\n-        if uid:\n-            user_static_path = await self.services.user.get_static_path(uid)\n-            if user_static_path:\n-                paths.append(user_static_path)\n-      \n-        for admin_uid in self.services.user.get_admin_uids():\n-            user_static_path = await self.services.user.get_static_path(admin_uid)\n-            if user_static_path:\n-                paths.append(user_static_path)\n-        \n-        paths.append(self.static_path)\n-\n-        for path in paths:\n-            if pathlib.Path(path).joinpath(file_name).exists():\n-                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n-        return web.HTTPNotFound()\n-\n-    async def get_user_template_loader(self, uid=None):\n-        template_paths = []\n-        for admin_uid in self.services.user.get_admin_uids():\n-            user_template_path = await self.services.user.get_template_path(admin_uid)\n-            if user_template_path:\n-                template_paths.append(user_template_path)\n-\n-        if uid:\n-            user_template_path = await self.services.user.get_template_path(uid)\n-            if user_template_path:\n-                template_paths.append(user_template_path)\n-\n-\n-        template_paths.append(self.template_path)\n-        return FileSystemLoader(template_paths)\n-\n-\n-\n-\n-async def main():\n-    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n-\n-\n-if __name__ == \"__main__\":\n-    asyncio.run(main())\n+\tdef __init__(A,*B,**C):D=[cors_middleware,web.normalize_path_middleware(merge_slashes=_C)];A.template_path=pathlib.Path(__file__).parent.joinpath('templates');A.static_path=pathlib.Path(__file__).parent.joinpath(_F);super().__init__(middlewares=D,template_path=A.template_path,client_max_size=5368709120*B,**C);session_setup(A,EncryptedCookieStorage(SESSION_KEY));A.tasks=asyncio.Queue();A._middlewares.append(session_middleware);A._middlewares.append(auth_middleware);A.jinja2_env.add_extension(MarkdownExtension);A.jinja2_env.add_extension(LinkifyExtension);A.jinja2_env.add_extension(PythonExtension);A.jinja2_env.add_extension(EmojiExtension);A.setup_router();A.executor=_D;A.cache=Cache(A);A.services=get_services(app=A);A.mappers=get_mappers(app=A);A.on_startup.append(A.prepare_asyncio);A.on_startup.append(A.prepare_database)\n+\tasync def prepare_asyncio(A,app):app.executor=ThreadPoolExecutor(max_workers=200);app.loop.set_default_executor(A.executor)\n+\tasync def create_task(A,task):await A.tasks.put(task)\n+\tasync def task_runner(A):\n+\t\twhile _C:\n+\t\t\tB=await A.tasks.get();A.db.begin()\n+\t\t\ttry:C=time.time();await B;D=time.time();print(f\"Task {B} took {D-C} seconds\");A.tasks.task_done()\n+\t\t\texcept Exception as E:print(E)\n+\t\t\tA.db.commit()\n+\tasync def prepare_database(A,app):\n+\t\tC='channel_message';D='channel_member';E='username';B='user_uid';A.db.query('PRAGMA journal_mode=WAL');A.db.query('PRAGMA syncnorm=off')\n+\t\ttry:\n+\t\t\tif not A.db[_E].has_index(E):A.db[_E].create_index(E,unique=_C)\n+\t\t\tif not A.db[D].has_index([_B,B]):A.db[D].create_index([_B,B])\n+\t\t\tif not A.db[C].has_index([_B,B]):A.db[C].create_index([_B,B])\n+\t\texcept:pass\n+\t\tawait app.services.drive.prepare_all();A.loop.create_task(A.task_runner())\n+\tdef setup_router(A):A.router.add_get('/',IndexView);A.router.add_static('/',pathlib.Path(__file__).parent.joinpath(_F),name=_F,show_index=_C);A.router.add_view('/profiler.html',profiler_handler);A.router.add_view('/about.html',AboutHTMLView);A.router.add_view('/about.md',AboutMDView);A.router.add_view('/logout.json',LogoutView);A.router.add_view('/logout.html',LogoutView);A.router.add_view('/docs.html',DocsHTMLView);A.router.add_view('/docs.md',DocsMDView);A.router.add_view('/status.json',StatusView);A.router.add_view('/settings/index.html',SettingsIndexView);A.router.add_view('/settings/profile.html',SettingsProfileView);A.router.add_view('/settings/profile.json',SettingsProfileView);A.router.add_view('/web.html',WebView);A.router.add_view('/login.html',LoginView);A.router.add_view('/login.json',LoginView);A.router.add_view('/register.html',RegisterView);A.router.add_view('/register.json',RegisterView);A.router.add_view('/drive/{rel_path:.*}',DriveView);A.router.add_view('/drive.bin',UploadView);A.router.add_view('/drive.bin/{uid}.{ext}',UploadView);A.router.add_view('/search-user.html',SearchUserView);A.router.add_view('/search-user.json',SearchUserView);A.router.add_view('/avatar/{uid}.svg',AvatarView);A.router.add_get('/http-get',A.handle_http_get);A.router.add_get('/http-photo',A.handle_http_photo);A.router.add_get('/rpc.ws',RPCView);A.router.add_view('/channel/{channel}.html',WebView);A.router.add_view('/threads.html',ThreadsView);A.router.add_view('/terminal.ws',TerminalSocketView);A.router.add_view('/terminal.html',TerminalView);A.router.add_view('/drive.json',DriveView);A.router.add_view('/drive/{drive}.json',DriveView);A.router.add_view('/stats.json',StatsView);A.router.add_view('/user/{user}.html',UserView);A.router.add_view('/repository/{username}/{repo_name}',RepositoryView);A.router.add_view('/repository/{username}/{repo_name}/{rel_path:.*}',RepositoryView);A.router.add_view('/settings/repositories/index.html',RepositoriesIndexView);A.router.add_view('/settings/repositories/create.html',RepositoriesCreateView);A.router.add_view('/settings/repositories/repository/{name}/update.html',RepositoriesUpdateView);A.router.add_view('/settings/repositories/repository/{name}/delete.html',RepositoriesDeleteView);A.webdav=WebdavApplication(A);A.git=GitApplication(A);A.add_subapp('/webdav',A.webdav);A.add_subapp('/git',A.git)\n+\tasync def handle_test(A,request):return await A.render_template('test.html',request,context={_G:'retoor'})\n+\tasync def handle_http_get(C,request):A=request.query.get('url');B=await http.get(A);return web.Response(body=B)\n+\tasync def handle_http_photo(C,request):A=request.query.get('url');B=await http.create_site_photo(A);return web.Response(body=B.read_bytes(),headers={'Content-Type':'image/png'})\n+\tasync def render_template(A,template,request,context=_D):\n+\t\tI='channels';J='new_count';K='color';L=template;F='last_message_on';D=request;C=context;G=[]\n+\t\tif not C:C={}\n+\t\tC['rid']=str(uuid.uuid4())\n+\t\tif D.session.get(_A):\n+\t\t\tasync for E in A.services.channel_member.find(user_uid=D.session.get(_A),deleted_at=_D,is_banned=False):\n+\t\t\t\tB={};M=await A.services.channel_member.get_other_dm_user(E[_B],D.session.get(_A));H=await E.get_channel();N=await H.get_last_message();O=_D\n+\t\t\t\tif N:P=await N.get_user();O=P[K]\n+\t\t\t\tB[K]=O;B[F]=H[F];B['is_private']=H['tag']=='dm'\n+\t\t\t\tif M:B[_G]=M['nick'];B[_A]=E[_B]\n+\t\t\t\telse:B[_G]=E['label'];B[_A]=E[_B]\n+\t\t\t\tB[J]=E[J];G.append(B)\n+\t\t\tG.sort(key=lambda x:x[F]or'',reverse=_C)\n+\t\t\tif I not in C:C[I]=G\n+\t\t\tif _E not in C:C[_E]=await A.services.user.get(D.session.get(_A))\n+\t\tA.template_path.joinpath(L);await A.services.user.get_template_path(D.session.get(_A));A.original_loader=A.jinja2_env.loader;A.jinja2_env.loader=await A.get_user_template_loader(D.session.get(_A));Q=await super().render_template(L,D,C);A.jinja2_env.loader=A.original_loader;return Q\n+\tasync def static_handler(B,request):\n+\t\tD=request;E=D.match_info.get('filename','');C=[];F=D.session.get(_A)\n+\t\tif F:\n+\t\t\tA=await B.services.user.get_static_path(F)\n+\t\t\tif A:C.append(A)\n+\t\tfor H in B.services.user.get_admin_uids():\n+\t\t\tA=await B.services.user.get_static_path(H)\n+\t\t\tif A:C.append(A)\n+\t\tC.append(B.static_path)\n+\t\tfor G in C:\n+\t\t\tif pathlib.Path(G).joinpath(E).exists():return web.FileResponse(pathlib.Path(G).joinpath(E))\n+\t\treturn web.HTTPNotFound()\n+\tasync def get_user_template_loader(B,uid=_D):\n+\t\tC=[]\n+\t\tfor D in B.services.user.get_admin_uids():\n+\t\t\tA=await B.services.user.get_template_path(D)\n+\t\t\tif A:C.append(A)\n+\t\tif uid:\n+\t\t\tA=await B.services.user.get_template_path(uid)\n+\t\t\tif A:C.append(A)\n+\t\tC.append(B.template_path);return FileSystemLoader(C)\n+async def main():await web._run_app(app,port=8081,host='0.0.0.0')\n+if __name__=='__main__':asyncio.run(main())\n\\ No newline at end of file\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 50a4245..b47df44 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,43 +1,14 @@\n import pathlib\n-\n from aiohttp import web\n from app.app import Application as BaseApplication\n-\n from snek.system.markdown import MarkdownExtension\n-\n-\n class Application(BaseApplication):\n-\n-    def __init__(self, path=None, *args, **kwargs):\n-        self.path = pathlib.Path(path)\n-        template_path = self.path\n-\n-        super().__init__(template_path=template_path, *args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-\n-        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n-\n-    async def handle_document(self, request):\n-        relative_path = request.match_info[\"tail\"].strip(\"/\")\n-        if relative_path == \"\":\n-            relative_path = \"index.html\"\n-        document_path = self.path.joinpath(relative_path)\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-        if document_path.is_dir():\n-            document_path = document_path.joinpath(\"index.html\")\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-\n-        response = await self.render_template(\n-            str(document_path.relative_to(self.path)), request\n-        )\n-        return response\n+\tdef __init__(A,path=None,*B,**C):A.path=pathlib.Path(path);D=A.path;super().__init__(*B,template_path=D,**C);A.jinja2_env.add_extension(MarkdownExtension);A.router.add_get('/{tail:.*}',A.handle_document)\n+\tasync def handle_document(B,request):\n+\t\tD='text/plain';E=b'Resource is not found on this server.';F='index.html';G=request;C=G.match_info['tail'].strip('/')\n+\t\tif C=='':C=F\n+\t\tA=B.path.joinpath(C)\n+\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n+\t\tif A.is_dir():A=A.joinpath(F)\n+\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n+\t\tH=await B.render_template(str(A.relative_to(B.path)),G);return H\n\\ No newline at end of file\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex b254756..2d52196 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,39 +1,12 @@\n+_B='created_at'\n+_A='uid'\n import asyncio\n-\n from snek.app import app\n-\n-\n-async def fix_message(message):\n-    message = {\n-        \"uid\": message[\"uid\"],\n-        \"user_uid\": message[\"user_uid\"],\n-        \"text\": message[\"message\"],\n-        \"sent\": message[\"created_at\"],\n-    }\n-    user = await app.services.user.get(uid=message[\"user_uid\"])\n-    message[\"user\"] = user and user[\"username\"] or None\n-    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n-\n-\n+async def fix_message(message):C='user';D='text';B='user_uid';A=message;A={_A:A[_A],B:A[B],D:A['message'],'sent':A[_B]};E=await app.services.user.get(uid=A[B]);A[C]=E and E['username']or None;return(A[C]or'')+': '+(A[D]or'')\n async def dump_public_channels():\n-    result = []\n-    for channel in app.db[\"channel\"].find(\n-        is_private=False, is_listed=True, tag=\"public\"\n-    ):\n-        print(f\"Dumping channel: {channel['label']}.\")\n-        result += [\n-            await fix_message(record)\n-            for record in app.db[\"channel_message\"].find(\n-                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n-            )\n-        ]\n-        print(\"Dump succesfull!\")\n-    print(\"Converting to json.\")\n-    print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.txt\", \"w\") as f:\n-        f.write(\"\\n\\n\".join(result))\n-    print(\"Dump written to dump.json\")\n-\n-\n-if __name__ == \"__main__\":\n-    asyncio.run(dump_public_channels())\n+\tA=[]\n+\tfor B in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):print(f\"Dumping channel: {B[\"label\"]}.\");A+=[await fix_message(A)for A in app.db['channel_message'].find(channel_uid=B[_A],order_by=_B)];print('Dump succesfull!')\n+\tprint('Converting to json.');print('Converting succesful, now writing to dump.json')\n+\twith open('dump.txt','w')as C:C.write('\\n\\n'.join(A))\n+\tprint('Dump written to dump.json')\n+if __name__=='__main__':asyncio.run(dump_public_channels())\n\\ No newline at end of file\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex ef13d67..c0b8cfd 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,51 +1,14 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n+_B='username'\n+_A='password'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n class AuthField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.model.password.value and self.model.username.value:\n-            if not await self.app.services.user.validate_login(\n-                self.model.username.value, self.model.password.value\n-            ):\n-                return [\"Invalid username or password\"]\n-        return result\n-\n-\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=await super().errors\n+\t\tif A.model.password.value and A.model.username.value:\n+\t\t\tif not await A.app.services.user.validate_login(A.model.username.value,A.model.password.value):return['Invalid username or password']\n+\t\treturn B\n class LoginForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Login\")\n-\n-    username = AuthField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\",\n-    )\n-    password = AuthField(\n-        name=\"password\",\n-        required=True,\n-        min_length=1,\n-        type=\"password\",\n-        place_holder=\"Password\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n-    )\n-\n-    @property\n-    async def is_valid(self):\n-        return all(\n-            [\n-                self[\"username\"],\n-                self[\"password\"],\n-                not await self.username.errors,\n-                not await self.password.errors,\n-            ]\n-        )\n+\ttitle=HTMLElement(tag='h1',text='Login');username=AuthField(name=_B,required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');password=AuthField(name=_A,required=True,min_length=1,type=_A,place_holder='Password');action=FormButtonElement(name='action',value='submit',text='Login',type='button')\n+\t@property\n+\tasync def is_valid(self):A=self;return all([A[_B],A[_A],not await A.username.errors,not await A.password.errors])\n\\ No newline at end of file\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex b105696..a9f8c71 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,44 +1,10 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n+_B='password'\n+_A='Register'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n class UsernameField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.value and await self.app.services.user.count(username=self.value):\n-            result.append(\"Username is not available.\")\n-        return result\n-\n-\n-class RegisterForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Register\")\n-\n-    username = UsernameField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\",\n-    )\n-    email = FormInputElement(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        type=\"email\",\n-    )\n-    password = FormInputElement(\n-        name=\"password\",\n-        required=True,\n-        min_length=1,\n-        type=\"password\",\n-        place_holder=\"Password\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n-    )\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=await super().errors\n+\t\tif A.value and await A.app.services.user.count(username=A.value):B.append('Username is not available.')\n+\t\treturn B\n+class RegisterForm(Form):title=HTMLElement(tag='h1',text=_A);username=UsernameField(name='username',required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');email=FormInputElement(name='email',required=False,regex='^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$',place_holder='Email address',type='email');password=FormInputElement(name=_B,required=True,min_length=1,type=_B,place_holder='Password');action=FormButtonElement(name='action',value='submit',text=_A,type='button')\n\\ No newline at end of file\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex 7e946b9..bf6de66 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -1,18 +1,2 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n-class SearchUserForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n-\n-    username = FormInputElement(\n-        name=\"username\",\n-        required=True,\n-        min_length=1,\n-        max_length=128,\n-        place_holder=\"Username\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n-    )\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+class SearchUserForm(Form):title=HTMLElement(tag='h1',text='Search user');username=FormInputElement(name='username',required=True,min_length=1,max_length=128,place_holder='Username');action=FormButtonElement(name='action',value='submit',text='Search',type='button')\n\\ No newline at end of file\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 836cd67..094c28e 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,25 +1,5 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n-class SettingsProfileForm(Form):\n-\n-    nick = FormInputElement(\n-        name=\"nick\",\n-        required=True,\n-        place_holder=\"Your Nickname\",\n-        min_length=1,\n-        max_length=20,\n-    )\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n-    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n-    profile = FormInputElement(\n-        name=\"profile\",\n-        place_holder=\"Tell about yourself.\",\n-        required=False,\n-        max_length=300,\n-    )\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n+_C='button'\n+_B='submit'\n+_A='action'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+class SettingsProfileForm(Form):nick=FormInputElement(name='nick',required=True,place_holder='Your Nickname',min_length=1,max_length=20);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C);title=HTMLElement(tag='h1',text='Profile');profile=FormInputElement(name='profile',place_holder='Tell about yourself.',required=False,max_length=300);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C)\n\\ No newline at end of file\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex 8583142..b51f326 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,3 +1,2 @@\n from snek.app import app\n-\n-application = app\n+application=app\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex ab7904f..917ab7d 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n@@ -10,24 +9,6 @@ from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_mappers(app=None):\n-    return Object(\n-        **{\n-            \"user\": UserMapper(app=app),\n-            \"channel_member\": ChannelMemberMapper(app=app),\n-            \"channel\": ChannelMapper(app=app),\n-            \"channel_message\": ChannelMessageMapper(app=app),\n-            \"notification\": NotificationMapper(app=app),\n-            \"drive_item\": DriveItemMapper(app=app),\n-            \"drive\": DriveMapper(app=app),\n-            \"user_property\": UserPropertyMapper(app=app),\n-            \"repository\": RepositoryMapper(app=app),\n-        }\n-    )\n-\n-\n-def get_mapper(name, app=None):\n-    return get_mappers(app=app)[name]\n+def get_mappers(app=None):A=app;return Object(**{'user':UserMapper(app=A),'channel_member':ChannelMemberMapper(app=A),'channel':ChannelMapper(app=A),'channel_message':ChannelMessageMapper(app=A),'notification':NotificationMapper(app=A),'drive_item':DriveItemMapper(app=A),'drive':DriveMapper(app=A),'user_property':UserPropertyMapper(app=A),'repository':RepositoryMapper(app=A)})\n+def get_mapper(name,app=None):return get_mappers(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nindex 6239dc8..d663d5b 100644\n--- a/src/snek/mapper/channel.py\n+++ b/src/snek/mapper/channel.py\n@@ -1,7 +1,3 @@\n from snek.model.channel import ChannelModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMapper(BaseMapper):\n-    table_name = \"channel\"\n-    model_class = ChannelModel\n+class ChannelMapper(BaseMapper):table_name='channel';model_class=ChannelModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nindex f0f62d6..b221d99 100644\n--- a/src/snek/mapper/channel_member.py\n+++ b/src/snek/mapper/channel_member.py\n@@ -1,7 +1,3 @@\n from snek.model.channel_member import ChannelMemberModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMemberMapper(BaseMapper):\n-    table_name = \"channel_member\"\n-    model_class = ChannelMemberModel\n+class ChannelMemberMapper(BaseMapper):table_name='channel_member';model_class=ChannelMemberModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex 35ccbe9..c27a9cb 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -1,7 +1,3 @@\n from snek.model.channel_message import ChannelMessageModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMessageMapper(BaseMapper):\n-    model_class = ChannelMessageModel\n-    table_name = \"channel_message\"\n+class ChannelMessageMapper(BaseMapper):model_class=ChannelMessageModel;table_name='channel_message'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex c92c687..cac318a 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -1,7 +1,3 @@\n from snek.model.drive import DriveModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class DriveMapper(BaseMapper):\n-    table_name = \"drive\"\n-    model_class = DriveModel\n+class DriveMapper(BaseMapper):table_name='drive';model_class=DriveModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex 3d17a61..96676f6 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,8 +1,3 @@\n from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class DriveItemMapper(BaseMapper):\n-\n-    model_class = DriveItemModel\n-    table_name = \"drive_item\"\n+class DriveItemMapper(BaseMapper):model_class=DriveItemModel;table_name='drive_item'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex 9bd74b5..c2372ce 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,7 +1,3 @@\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class NotificationMapper(BaseMapper):\n-    table_name = \"notification\"\n-    model_class = NotificationModel\n+class NotificationMapper(BaseMapper):table_name='notification';model_class=NotificationModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nindex 1ac10d4..1c04ba3 100644\n--- a/src/snek/mapper/repository.py\n+++ b/src/snek/mapper/repository.py\n@@ -1,7 +1,3 @@\n from snek.model.repository import RepositoryModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class RepositoryMapper(BaseMapper):\n-    model_class = RepositoryModel\n-    table_name = \"repository\"\n+class RepositoryMapper(BaseMapper):model_class=RepositoryModel;table_name='repository'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex e0df494..1df0eea 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,20 +1,7 @@\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n-\n-\n class UserMapper(BaseMapper):\n-    table_name = \"user\"\n-    model_class = UserModel\n-\n-    def get_admin_uids(self):\n-        try:\n-            return [\n-                user[\"uid\"]\n-                for user in self.db.query(\n-                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n-                    {\"is_admin\": True},\n-                )\n-            ]\n-        except Exception as ex:\n-            print(ex)\n-            return []\n+\ttable_name='user';model_class=UserModel\n+\tdef get_admin_uids(A):\n+\t\ttry:return[A['uid']for A in A.db.query('SELECT uid FROM user WHERE is_admin = :is_admin',{'is_admin':True})]\n+\t\texcept Exception as B:print(B);return[]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nindex 7359f60..654e769 100644\n--- a/src/snek/mapper/user_property.py\n+++ b/src/snek/mapper/user_property.py\n@@ -1,7 +1,3 @@\n from snek.model.user_property import UserPropertyModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class UserPropertyMapper(BaseMapper):\n-    table_name = \"user_property\"\n-    model_class = UserPropertyModel\n+class UserPropertyMapper(BaseMapper):table_name='user_property';model_class=UserPropertyModel\n\\ No newline at end of file\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 6399c89..bb7fb2a 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,9 +1,6 @@\n import functools\n-\n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n-\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.drive import DriveModel\n from snek.model.drive_item import DriveItemModel\n@@ -12,24 +9,6 @@ from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_models():\n-    return Object(\n-        **{\n-            \"user\": UserModel,\n-            \"channel_member\": ChannelMemberModel,\n-            \"channel\": ChannelModel,\n-            \"channel_message\": ChannelMessageModel,\n-            \"drive_item\": DriveItemModel,\n-            \"drive\": DriveModel,\n-            \"notification\": NotificationModel,\n-            \"user_property\": UserPropertyModel,\n-            \"repository\": RepositoryModel,\n-        }\n-    )\n-\n-\n-def get_model(name):\n-    return get_models()[name]\n+def get_models():return Object(**{'user':UserModel,'channel_member':ChannelMemberModel,'channel':ChannelModel,'channel_message':ChannelMessageModel,'drive_item':DriveItemModel,'drive':DriveModel,'notification':NotificationModel,'user_property':UserPropertyModel,'repository':RepositoryModel})\n+def get_model(name):return get_models()[name]\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 0a90c39..939d658 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,30 +1,12 @@\n+_C='uid'\n+_B=False\n+_A=True\n from snek.model.channel_message import ChannelMessageModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class ChannelModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True, kind=str)\n-    description = ModelField(name=\"description\", required=False, kind=str)\n-    tag = ModelField(name=\"tag\", required=False, kind=str)\n-    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n-    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n-    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n-    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n-    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n-\n-    async def get_last_message(self) -> ChannelMessageModel:\n-        try:\n-            async for model in self.app.services.channel_message.query(\n-                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n-                {\"channel_uid\": self[\"uid\"]},\n-            ):\n-\n-                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n-        except:\n-            pass\n-        return None\n-\n-    async def get_members(self):\n-        return await self.app.services.channel_member.find(\n-            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n-        )\n+\tlabel=ModelField(name='label',required=_A,kind=str);description=ModelField(name='description',required=_B,kind=str);tag=ModelField(name='tag',required=_B,kind=str);created_by_uid=ModelField(name='created_by_uid',required=_A,kind=str);is_private=ModelField(name='is_private',required=_A,kind=bool,value=_B);is_listed=ModelField(name='is_listed',required=_A,kind=bool,value=_A);index=ModelField(name='index',required=_A,kind=int,value=1000);last_message_on=ModelField(name='last_message_on',required=_B,kind=str)\n+\tasync def get_last_message(A):\n+\t\ttry:\n+\t\t\tasync for B in A.app.services.channel_message.query('SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1',{'channel_uid':A[_C]}):return await A.app.services.channel_message.get(uid=B[_C])\n+\t\texcept:pass\n+\tasync def get_members(A):return await A.app.services.channel_member.find(channel_uid=A[_C],deleted_at=None,is_banned=_B)\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 54b0418..09b7e91 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,41 +1,19 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+_D='channel_uid'\n+_C='user_uid'\n+_B=False\n+_A=True\n+from snek.system.model import BaseModel,ModelField\n class ChannelMemberModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True, kind=str)\n-    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    is_moderator = ModelField(\n-        name=\"is_moderator\", required=True, kind=bool, value=False\n-    )\n-    is_read_only = ModelField(\n-        name=\"is_read_only\", required=True, kind=bool, value=False\n-    )\n-    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n-    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n-    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n-\n-    async def get_user(self):\n-        return await self.app.services.user.get(uid=self[\"user_uid\"])\n-\n-    async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n-\n-    async def get_name(self):\n-        channel = await self.get_channel()\n-        if channel[\"tag\"] == \"dm\":\n-            user = await self.get_other_dm_user()\n-            return user[\"nick\"]\n-        return channel[\"name\"] or self[\"label\"]\n-\n-    async def get_other_dm_user(self):\n-        channel = await self.get_channel()\n-        if channel[\"tag\"] != \"dm\":\n-            return None\n-\n-        async for model in self.app.services.channel_member.find(\n-            channel_uid=channel[\"uid\"]\n-        ):\n-            if model[\"uid\"] != self[\"uid\"]:\n-                return await self.app.services.user.get(uid=model[\"user_uid\"])\n-        return await self.get_user()\n+\tlabel=ModelField(name='label',required=_A,kind=str);channel_uid=ModelField(name=_D,required=_A,kind=str);user_uid=ModelField(name=_C,required=_A,kind=str);is_moderator=ModelField(name='is_moderator',required=_A,kind=bool,value=_B);is_read_only=ModelField(name='is_read_only',required=_A,kind=bool,value=_B);is_muted=ModelField(name='is_muted',required=_A,kind=bool,value=_B);is_banned=ModelField(name='is_banned',required=_A,kind=bool,value=_B);new_count=ModelField(name='new_count',required=_B,kind=int,value=0)\n+\tasync def get_user(A):return await A.app.services.user.get(uid=A[_C])\n+\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_D])\n+\tasync def get_name(A):\n+\t\tB=await A.get_channel()\n+\t\tif B['tag']=='dm':C=await A.get_other_dm_user();return C['nick']\n+\t\treturn B['name']or A['label']\n+\tasync def get_other_dm_user(A):\n+\t\tB='uid';C=await A.get_channel()\n+\t\tif C['tag']!='dm':return\n+\t\tasync for D in A.app.services.channel_member.find(channel_uid=C[B]):\n+\t\t\tif D[B]!=A[B]:return await A.app.services.user.get(uid=D[_C])\n+\t\treturn await A.get_user()\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 524a8a4..0677d7c 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,15 +1,8 @@\n+_B='user_uid'\n+_A='channel_uid'\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class ChannelMessageModel(BaseModel):\n-    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    message = ModelField(name=\"message\", required=True, kind=str)\n-    html = ModelField(name=\"html\", required=False, kind=str)\n-\n-    async def get_user(self) -> UserModel:\n-        return await self.app.services.user.get(uid=self[\"user_uid\"])\n-\n-    async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n+\tchannel_uid=ModelField(name=_A,required=True,kind=str);user_uid=ModelField(name=_B,required=True,kind=str);message=ModelField(name='message',required=True,kind=str);html=ModelField(name='html',required=False,kind=str)\n+\tasync def get_user(A):return await A.app.services.user.get(uid=A[_B])\n+\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_A])\n\\ No newline at end of file\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex df17d0f..62a2846 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,14 +1,6 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class DriveModel(BaseModel):\n-\n-    user_uid = ModelField(name=\"user_uid\", required=True)\n-    name = ModelField(name=\"name\", required=False, type=str)\n-\n-    @property\n-    async def items(self):\n-        async for drive_item in self.app.services.drive_item.find(\n-            drive_uid=self[\"uid\"]\n-        ):\n-            yield drive_item\n+\tuser_uid=ModelField(name='user_uid',required=True);name=ModelField(name='name',required=False,type=str)\n+\t@property\n+\tasync def items(self):\n+\t\tasync for A in self.app.services.drive_item.find(drive_uid=self['uid']):yield A\n\\ No newline at end of file\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex e2b55b4..a4427f3 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,21 +1,10 @@\n+_B='name'\n+_A=True\n import mimetypes\n-\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class DriveItemModel(BaseModel):\n-    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n-    name = ModelField(name=\"name\", required=True, kind=str)\n-    path = ModelField(name=\"path\", required=True, kind=str)\n-    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n-    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n-    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n-\n-    @property\n-    def extension(self):\n-        return self[\"name\"].split(\".\")[-1]\n-\n-    @property\n-    def mime_type(self):\n-        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n-        return mimetype\n+\tdrive_uid=ModelField(name='drive_uid',required=_A,kind=str);name=ModelField(name=_B,required=_A,kind=str);path=ModelField(name='path',required=_A,kind=str);file_type=ModelField(name='file_type',required=_A,kind=str);file_size=ModelField(name='file_size',required=_A,kind=int);is_available=ModelField(name='is_available',required=_A,kind=bool,initial_value=_A)\n+\t@property\n+\tdef extension(self):return self[_B].split('.')[-1]\n+\t@property\n+\tdef mime_type(self):A,B=mimetypes.guess_type(self[_B]);return A\n\\ No newline at end of file\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex 6a12328..a8453eb 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,9 +1,3 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class NotificationModel(BaseModel):\n-    object_uid = ModelField(name=\"object_uid\", required=True)\n-    object_type = ModelField(name=\"object_type\", required=True)\n-    message = ModelField(name=\"message\", required=True)\n-    user_uid = ModelField(name=\"user_uid\", required=True)\n-    read_at = ModelField(name=\"is_read\", required=True)\n+_A=True\n+from snek.system.model import BaseModel,ModelField\n+class NotificationModel(BaseModel):object_uid=ModelField(name='object_uid',required=_A);object_type=ModelField(name='object_type',required=_A);message=ModelField(name='message',required=_A);user_uid=ModelField(name='user_uid',required=_A);read_at=ModelField(name='is_read',required=_A)\n\\ No newline at end of file\ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nindex 598cbb2..40ef94a 100644\n--- a/src/snek/model/repository.py\n+++ b/src/snek/model/repository.py\n@@ -1,14 +1,3 @@\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class RepositoryModel(BaseModel):\n-\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    \n-    name = ModelField(name=\"name\", required=True, kind=str)\n-\n-    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n-\n-\n-    \n+from snek.system.model import BaseModel,ModelField\n+class RepositoryModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);is_private=ModelField(name='is_private',required=False,kind=bool)\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 9869456..8572402 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,60 +1,17 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+_D='^[a-zA-Z0-9_-+/]+$'\n+_C=False\n+_B=True\n+_A='uid'\n+from snek.system.model import BaseModel,ModelField\n class UserModel(BaseModel):\n-\n-    username = ModelField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n-    )\n-    nick = ModelField(\n-        name=\"nick\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n-    )\n-    color = ModelField(\n-    )\n-    email = ModelField(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-    )\n-    password = ModelField(name=\"password\", required=True, min_length=1)\n-\n-    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n-\n-    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n-\n-    async def get_property(self, name):\n-        prop = await self.app.services.user_property.find_one(\n-            user_uid=self[\"uid\"], name=name\n-        )\n-        if prop:\n-            return prop[\"value\"]\n-\n-    async def has_property(self, name):\n-        return await self.app.services.user_property.exists(\n-            user_uid=self[\"uid\"], name=name\n-        )\n-\n-    async def set_property(self, name, value):\n-        if not await self.has_property(name):\n-            await self.app.services.user_property.insert(\n-                user_uid=self[\"uid\"], name=name, value=value\n-            )\n-        else:\n-            await self.app.services.user_property.update(\n-                user_uid=self[\"uid\"], name=name, value=value\n-            )\n-\n-    async def get_channel_members(self):\n-        async for channel_member in self.app.services.channel_member.find(\n-            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n-        ):\n-            yield channel_member\n+\tasync def get_property(A,name):\n+\t\tB=await A.app.services.user_property.find_one(user_uid=A[_A],name=name)\n+\t\tif B:return B['value']\n+\tasync def has_property(A,name):return await A.app.services.user_property.exists(user_uid=A[_A],name=name)\n+\tasync def set_property(A,name,value):\n+\t\tC=value;B=name\n+\t\tif not await A.has_property(B):await A.app.services.user_property.insert(user_uid=A[_A],name=B,value=C)\n+\t\telse:await A.app.services.user_property.update(user_uid=A[_A],name=B,value=C)\n+\tasync def get_channel_members(A):\n+\t\tasync for B in A.app.services.channel_member.find(user_uid=A[_A],is_banned=_C,deleted_at=None):yield B\n\\ No newline at end of file\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 1231423..77e5b25 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,7 +1,2 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class UserPropertyModel(BaseModel):\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    name = ModelField(name=\"name\", required=True, kind=str)\n-    value = ModelField(name=\"path\", required=True, kind=str)\n+from snek.system.model import BaseModel,ModelField\n+class UserPropertyModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);value=ModelField(name='path',required=True,kind=str)\n\\ No newline at end of file\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex be356dc..583ef6c 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-\n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n@@ -13,27 +12,6 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_services(app):\n-    return Object(\n-        **{\n-            \"user\": UserService(app=app),\n-            \"channel_member\": ChannelMemberService(app=app),\n-            \"channel\": ChannelService(app=app),\n-            \"channel_message\": ChannelMessageService(app=app),\n-            \"chat\": ChatService(app=app),\n-            \"socket\": SocketService(app=app),\n-            \"notification\": NotificationService(app=app),\n-            \"util\": UtilService(app=app),\n-            \"drive\": DriveService(app=app),\n-            \"drive_item\": DriveItemService(app=app),\n-            \"user_property\": UserPropertyService(app=app),\n-            \"repository\": RepositoryService(app=app),\n-        }\n-    )\n-\n-\n-def get_service(name, app=None):\n-    return get_services(app=app)[name]\n+def get_services(app):A=app;return Object(**{'user':UserService(app=A),'channel_member':ChannelMemberService(app=A),'channel':ChannelService(app=A),'channel_message':ChannelMessageService(app=A),'chat':ChatService(app=A),'socket':SocketService(app=A),'notification':NotificationService(app=A),'util':UtilService(app=A),'drive':DriveService(app=A),'drive_item':DriveItemService(app=A),'user_property':UserPropertyService(app=A),'repository':RepositoryService(app=A)})\n+def get_service(name,app=None):return get_services(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex b90e66f..8c39f6e 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,108 +1,49 @@\n+_F='channel_uid'\n+_E='public'\n+_D=True\n+_C='uid'\n+_B=None\n+_A=False\n from datetime import datetime\n-\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class ChannelService(BaseService):\n-    mapper_name = \"channel\"\n-\n-    async def get(self, uid=None, **kwargs):\n-        if uid:\n-            kwargs[\"uid\"] = uid\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            del kwargs[\"uid\"]\n-            kwargs[\"name\"] = uid\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            return None\n-        return await super().get(**kwargs)\n-\n-    async def create(\n-        self,\n-        label,\n-        created_by_uid,\n-        description=None,\n-        tag=None,\n-        is_private=False,\n-        is_listed=True,\n-    ):\n-        count = await self.count(deleted_at=None)\n-        if not tag and not count:\n-            tag = \"public\"\n-        model = await self.new()\n-        model[\"label\"] = label\n-        model[\"description\"] = description\n-        model[\"tag\"] = tag\n-        model[\"created_by_uid\"] = created_by_uid\n-        model[\"is_private\"] = is_private\n-        model[\"is_listed\"] = is_listed\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel: {model.errors}.\")\n-\n-    async def get_dm(self, user1, user2):\n-        channel_member = await self.services.channel_member.get_dm(user1, user2)\n-        if channel_member:\n-            return await self.get(uid=channel_member[\"channel_uid\"])\n-        channel = await self.create(\"DM\", user1, tag=\"dm\")\n-        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n-        return channel\n-\n-    async def get_users(self, channel_uid):\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_uid,\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n-            if user:\n-                yield user\n-\n-    async def get_online_users(self, channel_uid):\n-        async for user in self.get_users(channel_uid):\n-            if not user[\"last_ping\"]:\n-                continue\n-\n-            if (\n-                datetime.fromisoformat(now())\n-                - datetime.fromisoformat(user[\"last_ping\"])\n-            ).total_seconds() < 20:\n-                yield user\n-\n-    async def get_for_user(self, user_uid):\n-        async for channel_member in self.services.channel_member.find(\n-            user_uid=user_uid,\n-            is_banned=False,\n-            deleted_at=None,\n-        ):\n-            channel = await self.get(uid=channel_member[\"channel_uid\"])\n-            yield channel\n-\n-    async def ensure_public_channel(self, created_by_uid):\n-        model = await self.get(is_listed=True, tag=\"public\")\n-        is_moderator = False\n-        if not model:\n-            is_moderator = True\n-            model = await self.create(\n-                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n-            )\n-        await self.app.services.channel_member.create(\n-            model[\"uid\"],\n-            created_by_uid,\n-            is_moderator=is_moderator,\n-            is_read_only=False,\n-            is_muted=False,\n-            is_banned=False,\n-        )\n-        return model\n+\tmapper_name='channel'\n+\tasync def get(E,uid=_B,**A):\n+\t\tD='name';C=uid\n+\t\tif C:\n+\t\t\tA[_C]=C;B=await super().get(**A)\n+\t\t\tif B:return B\n+\t\t\tdel A[_C];A[D]=C;B=await super().get(**A)\n+\t\t\tif B:return B\n+\t\t\tif B:return B\n+\t\t\treturn\n+\t\treturn await super().get(**A)\n+\tasync def create(C,label,created_by_uid,description=_B,tag=_B,is_private=_A,is_listed=_D):\n+\t\tE=is_listed;D=tag;B=label\n+\t\tF=await C.count(deleted_at=_B)\n+\t\tif not D and not F:D=_E\n+\t\tA=await C.new();A['label']=B;A['description']=description;A['tag']=D;A['created_by_uid']=created_by_uid;A['is_private']=is_private;A['is_listed']=E\n+\t\tif await C.save(A):return A\n+\t\traise Exception(f\"Failed to create channel: {A.errors}.\")\n+\tasync def get_dm(A,user1,user2):\n+\t\tC=user2;B=user1;D=await A.services.channel_member.get_dm(B,C)\n+\t\tif D:return await A.get(uid=D[_F])\n+\t\tE=await A.create('DM',B,tag='dm');await A.services.channel_member.create_dm(E[_C],B,C);return E\n+\tasync def get_users(A,channel_uid):\n+\t\tasync for C in A.services.channel_member.find(channel_uid=channel_uid,is_banned=_A,is_muted=_A,deleted_at=_B):\n+\t\t\tB=await A.services.user.get(uid=C['user_uid'])\n+\t\t\tif B:yield B\n+\tasync def get_online_users(C,channel_uid):\n+\t\tB='last_ping'\n+\t\tasync for A in C.get_users(channel_uid):\n+\t\t\tif not A[B]:continue\n+\t\t\tif(datetime.fromisoformat(now())-datetime.fromisoformat(A[B])).total_seconds()<20:yield A\n+\tasync def get_for_user(A,user_uid):\n+\t\tasync for B in A.services.channel_member.find(user_uid=user_uid,is_banned=_A,deleted_at=_B):C=await A.get(uid=B[_F]);yield C\n+\tasync def ensure_public_channel(B,created_by_uid):\n+\t\tC=created_by_uid;A=await B.get(is_listed=_D,tag=_E);D=_A\n+\t\tif not A:D=_D;A=await B.create(_E,created_by_uid=C,is_listed=_D,tag=_E)\n+\t\tawait B.app.services.channel_member.create(A[_C],C,is_moderator=D,is_read_only=_A,is_muted=_A,is_banned=_A);return A\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex df96786..cd3a62b 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,74 +1,28 @@\n+_C='user_uid'\n+_B='channel_uid'\n+_A=False\n from snek.system.service import BaseService\n-\n-\n class ChannelMemberService(BaseService):\n-\n-    mapper_name = \"channel_member\"\n-\n-    async def mark_as_read(self, channel_uid, user_uid):\n-        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel_member[\"new_count\"] = 0\n-        return await self.save(channel_member)\n-\n-    async def get_user_uids(self, channel_uid):\n-        async for model in self.mapper.query(\n-            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n-            {\"channel_uid\": channel_uid},\n-        ):\n-            yield model[\"user_uid\"]\n-\n-    async def create(\n-        self,\n-        channel_uid,\n-        user_uid,\n-        is_moderator=False,\n-        is_read_only=False,\n-        is_muted=False,\n-        is_banned=False,\n-    ):\n-        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        if model:\n-            if model[\"is_banned\"]:\n-                return False\n-            return model\n-        model = await self.new()\n-        channel = await self.services.channel.get(uid=channel_uid)\n-        model[\"label\"] = channel[\"label\"]\n-        model[\"channel_uid\"] = channel_uid\n-        model[\"user_uid\"] = user_uid\n-        model[\"is_moderator\"] = is_moderator\n-        model[\"is_read_only\"] = is_read_only\n-        model[\"is_muted\"] = is_muted\n-        model[\"is_banned\"] = is_banned\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n-\n-    async def get_dm(self, from_user, to_user):\n-        async for model in self.query(\n-            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n-            {\"from_user\": from_user, \"to_user\": to_user},\n-        ):\n-            return model\n-        if not from_user == to_user:\n-            return None\n-        async for model in self.query(\n-            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n-            {\"from_user\": from_user, \"to_user\": to_user},\n-        ):\n-\n-            return model\n-\n-    async def get_other_dm_user(self, channel_uid, user_uid):\n-        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n-        if channel[\"tag\"] != \"dm\":\n-            return None\n-        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            if model[\"uid\"] != channel_member[\"uid\"]:\n-                return await self.services.user.get(uid=model[\"user_uid\"])\n-\n-    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n-        result = await self.create(channel_uid, from_user_uid)\n-        await self.create(channel_uid, to_user_uid)\n-        return result\n+\tmapper_name='channel_member'\n+\tasync def mark_as_read(A,channel_uid,user_uid):B=await A.get(channel_uid=channel_uid,user_uid=user_uid);B['new_count']=0;return await A.save(B)\n+\tasync def get_user_uids(A,channel_uid):\n+\t\tasync for B in A.mapper.query('SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid',{_B:channel_uid}):yield B[_C]\n+\tasync def create(B,channel_uid,user_uid,is_moderator=_A,is_read_only=_A,is_muted=_A,is_banned=_A):\n+\t\tD='label';E='is_banned';F=user_uid;C=channel_uid;A=await B.get(channel_uid=C,user_uid=F)\n+\t\tif A:\n+\t\t\tif A[E]:return _A\n+\t\t\treturn A\n+\t\tA=await B.new();G=await B.services.channel.get(uid=C);A[D]=G[D];A[_B]=C;A[_C]=F;A['is_moderator']=is_moderator;A['is_read_only']=is_read_only;A['is_muted']=is_muted;A[E]=is_banned\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create channel member: {A.errors}.\")\n+\tasync def get_dm(D,from_user,to_user):\n+\t\tE='to_user';F='from_user';A=to_user;B=from_user\n+\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n+\t\tif not B==A:return\n+\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n+\tasync def get_other_dm_user(A,channel_uid,user_uid):\n+\t\tB='uid';C=channel_uid;D=await A.get(channel_uid=C,user_uid=user_uid);F=await A.services.channel.get(uid=D[_B])\n+\t\tif F['tag']!='dm':return\n+\t\tasync for E in A.services.channel_member.find(channel_uid=C):\n+\t\t\tif E[B]!=D[B]:return await A.services.user.get(uid=E[_C])\n+\tasync def create_dm(A,channel_uid,from_user_uid,to_user_uid):B=channel_uid;C=await A.create(B,from_user_uid);await A.create(B,to_user_uid);return C\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex f8a000f..1841024 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,93 +1,33 @@\n+_I='user_nick'\n+_H='created_at'\n+_G='html'\n+_F='uid'\n+_E='message'\n+_D='color'\n+_C='username'\n+_B='user_uid'\n+_A='channel_uid'\n from snek.system.service import BaseService\n-\n-\n class ChannelMessageService(BaseService):\n-    mapper_name = \"channel_message\"\n-\n-    async def create(self, channel_uid, user_uid, message):\n-        model = await self.new()\n-\n-        model[\"channel_uid\"] = channel_uid\n-        model[\"user_uid\"] = user_uid\n-        model[\"message\"] = message\n-\n-        context = {}\n-\n-        record = model.record\n-        context.update(record)\n-        user = await self.app.services.user.get(uid=user_uid)\n-        context.update(\n-            {\n-                \"user_uid\": user[\"uid\"],\n-                \"username\": user[\"username\"],\n-                \"user_nick\": user[\"nick\"],\n-                \"color\": user[\"color\"],\n-            }\n-        )\n-        try:\n-            template = self.app.jinja2_env.get_template(\"message.html\")\n-            model[\"html\"] = template.render(**context)\n-        except Exception as ex:\n-            print(ex, flush=True)\n-\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n-\n-    async def to_extended_dict(self, message):\n-        user = await self.services.user.get(uid=message[\"user_uid\"])\n-        if not user:\n-            return {}\n-        return {\n-            \"uid\": message[\"uid\"],\n-            \"color\": user[\"color\"],\n-            \"user_uid\": message[\"user_uid\"],\n-            \"channel_uid\": message[\"channel_uid\"],\n-            \"user_nick\": user[\"nick\"],\n-            \"message\": message[\"message\"],\n-            \"created_at\": message[\"created_at\"],\n-            \"html\": message[\"html\"],\n-            \"username\": user[\"username\"],\n-        }\n-\n-    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n-        results = []\n-        offset = page * page_size\n-        try:\n-            if timestamp:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                        \"timestamp\": timestamp,\n-                    },\n-                ):\n-                    results.append(model)\n-            elif page > 0:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                        \"timestamp\": timestamp,\n-                    },\n-                ):\n-                    results.append(model)\n-            else:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                    },\n-                ):\n-                    results.append(model)\n-\n-        except:\n-            pass\n-        results.sort(key=lambda x: x[\"created_at\"])\n-        return results\n+\tmapper_name='channel_message'\n+\tasync def create(B,channel_uid,user_uid,message):\n+\t\tE=user_uid;A=await B.new();A[_A]=channel_uid;A[_B]=E;A[_E]=message;D={};F=A.record;D.update(F);C=await B.app.services.user.get(uid=E);D.update({_B:C[_F],_C:C[_C],_I:C['nick'],_D:C[_D]})\n+\t\ttry:G=B.app.jinja2_env.get_template('message.html');A[_G]=G.render(**D)\n+\t\texcept Exception as H:print(H,flush=True)\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create channel message: {A.errors}.\")\n+\tasync def to_extended_dict(C,message):\n+\t\tA=message;B=await C.services.user.get(uid=A[_B])\n+\t\tif not B:return{}\n+\t\treturn{_F:A[_F],_D:B[_D],_B:A[_B],_A:A[_A],_I:B['nick'],_E:A[_E],_H:A[_H],_G:A[_G],_C:B[_C]}\n+\tasync def offset(D,channel_uid,page=0,timestamp=None,page_size=30):\n+\t\tJ='timestamp';E='offset';F='page_size';G=timestamp;H=channel_uid;C=page_size;A=[];I=page*C\n+\t\ttry:\n+\t\t\tif G:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I,J:G}):A.append(B)\n+\t\t\telif page>0:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size',{_A:H,F:C,E:I,J:G}):A.append(B)\n+\t\t\telse:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I}):A.append(B)\n+\t\texcept:pass\n+\t\tA.sort(key=lambda x:x[_H]);return A\n\\ No newline at end of file\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 388d5c0..14a9ad1 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,39 +1,7 @@\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class ChatService(BaseService):\n-\n-    async def send(self, user_uid, channel_uid, message):\n-        channel = await self.services.channel.get(uid=channel_uid)\n-        if not channel:\n-            raise Exception(\"Channel not found.\")\n-        channel_message = await self.services.channel_message.create(\n-            channel_uid, user_uid, message\n-        )\n-        channel_message_uid = channel_message[\"uid\"]\n-\n-        user = await self.services.user.get(uid=user_uid)\n-        channel[\"last_message_on\"] = now()\n-        await self.services.channel.save(channel)\n-\n-        await self.services.socket.broadcast(\n-            channel_uid,\n-            {\n-                \"message\": channel_message[\"message\"],\n-                \"html\": channel_message[\"html\"],\n-                \"user_uid\": user_uid,\n-                \"color\": user[\"color\"],\n-                \"channel_uid\": channel_uid,\n-                \"created_at\": channel_message[\"created_at\"],\n-                \"updated_at\": None,\n-                \"username\": user[\"username\"],\n-                \"uid\": channel_message[\"uid\"],\n-                \"user_nick\": user[\"nick\"],\n-            },\n-        )\n-        await self.app.create_task(\n-            self.services.notification.create_channel_message(channel_message_uid)\n-        )\n-\n-        return True\n+\tasync def send(A,user_uid,channel_uid,message):\n+\t\tH='username';I='created_at';J='color';K='html';L='message';D='uid';E=user_uid;C=channel_uid;F=await A.services.channel.get(uid=C)\n+\t\tif not F:raise Exception('Channel not found.')\n+\t\tB=await A.services.channel_message.create(C,E,message);M=B[D];G=await A.services.user.get(uid=E);F['last_message_on']=now();await A.services.channel.save(F);await A.services.socket.broadcast(C,{L:B[L],K:B[K],'user_uid':E,J:G[J],'channel_uid':C,I:B[I],'updated_at':None,H:G[H],D:B[D],'user_nick':G['nick']});await A.app.create_task(A.services.notification.create_channel_message(M));return True\n\\ No newline at end of file\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex 38035c7..e38b3fa 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -1,153 +1,41 @@\n+_H='Documents'\n+_G='Archives'\n+_F='Videos'\n+_E='Pictures'\n+_D='uid'\n+_C='user_uid'\n+_B='My Drive'\n+_A='name'\n from snek.system.service import BaseService\n-\n-\n class DriveService(BaseService):\n-\n-    mapper_name = \"drive\"\n-\n-    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n-    EXTENSIONS_VIDEOS = [\n-        \"mp4\",\n-        \"m4v\",\n-        \"mov\",\n-        \"wmv\",\n-        \"webm\",\n-        \"mkv\",\n-        \"mpg\",\n-        \"mpeg\",\n-        \"avi\",\n-        \"ogv\",\n-        \"ogg\",\n-        \"flv\",\n-        \"3gp\",\n-        \"3g2\",\n-    ]\n-    EXTENSIONS_ARCHIVES = [\n-        \"zip\",\n-        \"rar\",\n-        \"7z\",\n-        \"tar\",\n-        \"tar.gz\",\n-        \"tar.xz\",\n-        \"tar.bz2\",\n-        \"tar.lzma\",\n-        \"tar.lz\",\n-    ]\n-    EXTENSIONS_AUDIO = [\n-        \"mp3\",\n-        \"wav\",\n-        \"ogg\",\n-        \"flac\",\n-        \"m4a\",\n-        \"wma\",\n-        \"aac\",\n-        \"opus\",\n-        \"aiff\",\n-        \"au\",\n-        \"mid\",\n-        \"midi\",\n-    ]\n-    EXTENSIONS_DOCS = [\n-        \"pdf\",\n-        \"doc\",\n-        \"docx\",\n-        \"xls\",\n-        \"xlsx\",\n-        \"ppt\",\n-        \"pptx\",\n-        \"txt\",\n-        \"md\",\n-        \"json\",\n-        \"csv\",\n-        \"xml\",\n-        \"html\",\n-        \"css\",\n-        \"js\",\n-        \"py\",\n-        \"sql\",\n-        \"rs\",\n-        \"toml\",\n-        \"yml\",\n-        \"yaml\",\n-        \"ini\",\n-        \"conf\",\n-        \"config\",\n-        \"log\",\n-        \"csv\",\n-        \"tsv\",\n-        \"java\",\n-        \"cs\",\n-        \"csproj\",\n-        \"scss\",\n-        \"less\",\n-        \"sass\",\n-        \"json\",\n-        \"lock\",\n-        \"lock.json\",\n-        \"jsonl\",\n-    ]\n-\n-    async def get_drive_name_by_extension(self, extension):\n-        if extension.startswith(\".\"):\n-            extension = extension[1:]\n-        if extension in self.EXTENSIONS_PICTURES:\n-            return \"Pictures\"\n-        if extension in self.EXTENSIONS_VIDEOS:\n-            return \"Videos\"\n-        if extension in self.EXTENSIONS_ARCHIVES:\n-            return \"Archives\"\n-        if extension in self.EXTENSIONS_AUDIO:\n-            return \"Audio\"\n-        if extension in self.EXTENSIONS_DOCS:\n-            return \"Documents\"\n-        return \"My Drive\"\n-\n-    async def get_drive_by_extension(self, user_uid, extension):\n-        name = await self.get_drive_name_by_extension(extension)\n-        return await self.get_or_create(user_uid=user_uid, name=name)\n-\n-    async def get_by_user(self, user_uid, name=None):\n-        kwargs = {\"user_uid\": user_uid}\n-        async for model in self.find(**kwargs):\n-            if not name:\n-                yield model\n-            elif model[\"name\"] == name:\n-                yield model\n-            elif not model[\"name\"] and name == \"My Drive\":\n-                model[\"name\"] = \"My Drive\"\n-                await self.save(model)\n-                yield model\n-\n-    async def get_or_create(self, user_uid, name=None, extensions=None):\n-        kwargs = {\"user_uid\": user_uid}\n-        if name:\n-            kwargs[\"name\"] = name\n-        async for model in self.get_by_user(**kwargs):\n-            return model\n-\n-        model = await self.new()\n-        model[\"user_uid\"] = user_uid\n-        model[\"name\"] = name\n-        await self.save(model)\n-        return model\n-\n-    async def prepare_default_drives(self):\n-        async for drive_item in self.services.drive_item.find():\n-            extension = drive_item.extension\n-            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n-            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n-                drive_item[\"drive_uid\"] = drive[\"uid\"]\n-                await self.services.drive_item.save(drive_item)\n-\n-    async def prepare_default_drives_for_user(self, user_uid):\n-        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n-\n-    async def prepare_all(self):\n-        await self.prepare_default_drives()\n-        async for user in self.services.user.find():\n-            await self.prepare_default_drives_for_user(user[\"uid\"])\n+\tmapper_name='drive';EXTENSIONS_PICTURES=['jpg','jpeg','png','gif','svg','webp','tiff'];EXTENSIONS_VIDEOS=['mp4','m4v','mov','wmv','webm','mkv','mpg','mpeg','avi','ogv','ogg','flv','3gp','3g2'];EXTENSIONS_ARCHIVES=['zip','rar','7z','tar','tar.gz','tar.xz','tar.bz2','tar.lzma','tar.lz'];EXTENSIONS_AUDIO=['mp3','wav','ogg','flac','m4a','wma','aac','opus','aiff','au','mid','midi'];EXTENSIONS_DOCS=['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','json','csv','xml','html','css','js','py','sql','rs','toml','yml','yaml','ini','conf','config','log','csv','tsv','java','cs','csproj','scss','less','sass','json','lock','lock.json','jsonl']\n+\tasync def get_drive_name_by_extension(B,extension):\n+\t\tA=extension\n+\t\tif A.startswith('.'):A=A[1:]\n+\t\tif A in B.EXTENSIONS_PICTURES:return _E\n+\t\tif A in B.EXTENSIONS_VIDEOS:return _F\n+\t\tif A in B.EXTENSIONS_ARCHIVES:return _G\n+\t\tif A in B.EXTENSIONS_AUDIO:return'Audio'\n+\t\tif A in B.EXTENSIONS_DOCS:return _H\n+\t\treturn _B\n+\tasync def get_drive_by_extension(A,user_uid,extension):B=await A.get_drive_name_by_extension(extension);return await A.get_or_create(user_uid=user_uid,name=B)\n+\tasync def get_by_user(C,user_uid,name=None):\n+\t\tB=name;D={_C:user_uid}\n+\t\tasync for A in C.find(**D):\n+\t\t\tif not B:yield A\n+\t\t\telif A[_A]==B:yield A\n+\t\t\telif not A[_A]and B==_B:A[_A]=_B;await C.save(A);yield A\n+\tasync def get_or_create(B,user_uid,name=None,extensions=None):\n+\t\tD=user_uid;C=name;E={_C:D}\n+\t\tif C:E[_A]=C\n+\t\tasync for A in B.get_by_user(**E):return A\n+\t\tA=await B.new();A[_C]=D;A[_A]=C;await B.save(A);return A\n+\tasync def prepare_default_drives(B):\n+\t\tC='drive_uid'\n+\t\tasync for A in B.services.drive_item.find():\n+\t\t\tE=A.extension;D=await B.get_drive_by_extension(A[_C],E)\n+\t\t\tif not A[C]==D[_D]:A[C]=D[_D];await B.services.drive_item.save(A)\n+\tasync def prepare_default_drives_for_user(A,user_uid):B=user_uid;await A.get_or_create(user_uid=B,name=_B);await A.get_or_create(user_uid=B,name='Shared Drive');await A.get_or_create(user_uid=B,name=_E);await A.get_or_create(user_uid=B,name=_F);await A.get_or_create(user_uid=B,name=_G);await A.get_or_create(user_uid=B,name=_H)\n+\tasync def prepare_all(A):\n+\t\tawait A.prepare_default_drives()\n+\t\tasync for B in A.services.user.find():await A.prepare_default_drives_for_user(B[_D])\n\\ No newline at end of file\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex ce747c1..0740949 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,19 +1,7 @@\n from snek.system.service import BaseService\n-\n-\n class DriveItemService(BaseService):\n-\n-    mapper_name = \"drive_item\"\n-\n-    async def create(self, drive_uid, name, path, type_, size):\n-        model = await self.new()\n-        model[\"drive_uid\"] = drive_uid\n-        model[\"name\"] = name\n-        model[\"path\"] = str(path)\n-        model[\"extension\"] = str(name).split(\".\")[-1]\n-        model[\"file_type\"] = type_\n-        model[\"file_size\"] = size\n-        if await self.save(model):\n-            return model\n-        errors = await model.errors\n-        raise Exception(f\"Failed to create drive item: {errors}.\")\n+\tmapper_name='drive_item'\n+\tasync def create(B,drive_uid,name,path,type_,size):\n+\t\tA=await B.new();A['drive_uid']=drive_uid;A['name']=name;A['path']=str(path);A['extension']=str(name).split('.')[-1];A['file_type']=type_;A['file_size']=size\n+\t\tif await B.save(A):return A\n+\t\tC=await A.errors;raise Exception(f\"Failed to create drive item: {C}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a22e8ae..968d426 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,65 +1,28 @@\n+_E='message'\n+_D='object_type'\n+_C='object_uid'\n+_B=False\n+_A='user_uid'\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class NotificationService(BaseService):\n-    mapper_name = \"notification\"\n-\n-    async def mark_as_read(self, user_uid, channel_message_uid):\n-        model = await self.get(user_uid, object_uid=channel_message_uid)\n-        if not model:\n-            return False\n-        model[\"read_at\"] = now()\n-        await self.save(model)\n-        return True\n-\n-    async def get_unread_stats(self, user_uid):\n-        await self.query(\n-            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n-            {\"user_uid\": user_uid},\n-        )\n-\n-    async def create(self, object_uid, object_type, user_uid, message):\n-        model = await self.new()\n-        model[\"object_uid\"] = object_uid\n-        model[\"object_type\"] = object_type\n-        model[\"user_uid\"] = user_uid\n-        model[\"message\"] = message\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create notification: {model.errors}.\")\n-\n-    async def create_channel_message(self, channel_message_uid):\n-        channel_message = await self.services.channel_message.get(\n-            uid=channel_message_uid\n-        )\n-        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n-        self.app.db.begin()\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            if not channel_member[\"new_count\"]:\n-                channel_member[\"new_count\"] = 0\n-            channel_member[\"new_count\"] += 1\n-\n-            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n-            if not usr:\n-                continue\n-            await self.services.channel_member.save(channel_member)\n-\n-            model = await self.new()\n-            model[\"object_uid\"] = channel_message_uid\n-            model[\"object_type\"] = \"channel_message\"\n-            model[\"user_uid\"] = channel_member[\"user_uid\"]\n-            model[\"message\"] = (\n-                f\"New message from {user['nick']} in {channel_member['label']}.\"\n-            )\n-            try:\n-                await self.save(model)\n-            except Exception:\n-                raise Exception(f\"Failed to create notification: {model.errors}.\")\n-\n-        self.app.db.commit()\n+\tmapper_name='notification'\n+\tasync def mark_as_read(B,user_uid,channel_message_uid):\n+\t\tA=await B.get(user_uid,object_uid=channel_message_uid)\n+\t\tif not A:return _B\n+\t\tA['read_at']=now();await B.save(A);return True\n+\tasync def get_unread_stats(A,user_uid):await A.query('SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type',{_A:user_uid})\n+\tasync def create(B,object_uid,object_type,user_uid,message):\n+\t\tA=await B.new();A[_C]=object_uid;A[_D]=object_type;A[_A]=user_uid;A[_E]=message\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create notification: {A.errors}.\")\n+\tasync def create_channel_message(A,channel_message_uid):\n+\t\tE=channel_message_uid;D='new_count';F=await A.services.channel_message.get(uid=E);G=await A.services.user.get(uid=F[_A]);A.app.db.begin()\n+\t\tasync for B in A.services.channel_member.find(channel_uid=F['channel_uid'],is_banned=_B,is_muted=_B,deleted_at=None):\n+\t\t\tif not B[D]:B[D]=0\n+\t\t\tB[D]+=1;H=await A.services.user.get(uid=B[_A])\n+\t\t\tif not H:continue\n+\t\t\tawait A.services.channel_member.save(B);C=await A.new();C[_C]=E;C[_D]='channel_message';C[_A]=B[_A];C[_E]=f\"New message from {G[\"nick\"]} in {B[\"label\"]}.\"\n+\t\t\ttry:await A.save(C)\n+\t\t\texcept Exception:raise Exception(f\"Failed to create notification: {C.errors}.\")\n+\t\tA.app.db.commit()\n\\ No newline at end of file\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex 120c232..be30602 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,52 +1,23 @@\n+_B='user_uid'\n+_A=False\n from snek.system.service import BaseService\n-import asyncio \n-import shutil\n-\n+import asyncio,shutil\n class RepositoryService(BaseService):\n-    mapper_name = \"repository\"\n-\n-    async def delete(self, user_uid, name):\n-        loop = asyncio.get_event_loop()\n-        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n-        try:\n-            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n-        except Exception as ex:\n-            print(ex)\n-\n-        await super().delete(user_uid=user_uid, name=name)\n-\n-\n-    async def exists(self, user_uid, name, **kwargs):\n-        kwargs[\"user_uid\"] = user_uid\n-        kwargs[\"name\"] = name\n-        return await super().exists(**kwargs)\n-\n-    async def init(self, user_uid, name):\n-        repository_path = await self.services.user.get_repository_path(user_uid)\n-        if not repository_path.exists():\n-            repository_path.mkdir(parents=True)\n-        repository_path = repository_path.joinpath(name)\n-        repository_path = str(repository_path)\n-        if not repository_path.endswith(\".git\"):\n-            repository_path += \".git\"\n-        command = ['git', 'init', '--bare', repository_path]\n-        process = await asyncio.subprocess.create_subprocess_exec(\n-            *command,\n-            stdout=asyncio.subprocess.PIPE,\n-            stderr=asyncio.subprocess.PIPE\n-        )\n-        stdout, stderr = await process.communicate()\n-        return process.returncode == 0\n-\n-    async def create(self, user_uid, name,is_private=False):\n-        if await self.exists(user_uid=user_uid, name=name):\n-            return False \n-\n-        if not await self.init(user_uid=user_uid, name=name):\n-            return False\n-\n-        model = await self.new()\n-        model[\"user_uid\"] = user_uid\n-        model[\"name\"] = name\n-        model[\"is_private\"] = is_private\n-        return await self.save(model)\n+\tmapper_name='repository'\n+\tasync def delete(B,user_uid,name):\n+\t\tA=user_uid;C=asyncio.get_event_loop();D=(await B.services.user.get_repository_path(A)).joinpath(name)\n+\t\ttry:await C.run_in_executor(None,shutil.rmtree,D)\n+\t\texcept Exception as E:print(E)\n+\t\tawait super().delete(user_uid=A,name=name)\n+\tasync def exists(B,user_uid,name,**A):A[_B]=user_uid;A['name']=name;return await super().exists(**A)\n+\tasync def init(D,user_uid,name):\n+\t\tB='.git';A=await D.services.user.get_repository_path(user_uid)\n+\t\tif not A.exists():A.mkdir(parents=True)\n+\t\tA=A.joinpath(name);A=str(A)\n+\t\tif not A.endswith(B):A+=B\n+\t\tE=['git','init','--bare',A];C=await asyncio.subprocess.create_subprocess_exec(*E,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);F,G=await C.communicate();return C.returncode==0\n+\tasync def create(A,user_uid,name,is_private=_A):\n+\t\tC=name;D=user_uid\n+\t\tif await A.exists(user_uid=D,name=C):return _A\n+\t\tif not await A.init(user_uid=D,name=C):return _A\n+\t\tB=await A.new();B[_B]=D;B['name']=C;B['is_private']=is_private;return await A.save(B)\n\\ No newline at end of file\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a3654d2..86c83a8 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,71 +1,36 @@\n+_B=False\n+_A=True\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n-\n-\n class SocketService(BaseService):\n-\n-    class Socket:\n-        def __init__(self, ws, user: UserModel):\n-            self.ws = ws\n-            self.is_connected = True\n-            self.user = user\n-\n-        async def send_json(self, data):\n-            if not self.is_connected:\n-                return False\n-            try:\n-                await self.ws.send_json(data)\n-            except Exception:\n-                self.is_connected = False\n-            return self.is_connected\n-\n-        async def close(self):\n-            if not self.is_connected:\n-                return True\n-\n-            await self.ws.close()\n-            self.is_connected = False\n-\n-            return True\n-\n-    def __init__(self, app):\n-        super().__init__(app)\n-        self.sockets = set()\n-        self.users = {}\n-        self.subscriptions = {}\n-\n-    async def add(self, ws, user_uid):\n-        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n-        self.sockets.add(s)\n-        if not self.users.get(user_uid):\n-            self.users[user_uid] = set()\n-        self.users[user_uid].add(s)\n-\n-    async def subscribe(self, ws, channel_uid, user_uid):\n-        if channel_uid not in self.subscriptions:\n-            self.subscriptions[channel_uid] = set()\n-        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n-        self.subscriptions[channel_uid].add(s)\n-\n-    async def send_to_user(self, user_uid, message):\n-        count = 0\n-        for s in self.users.get(user_uid, []):\n-            if await s.send_json(message):\n-                count += 1\n-        return count\n-\n-    async def broadcast(self, channel_uid, message):\n-        try:\n-            async for user_uid in self.services.channel_member.get_user_uids(\n-                channel_uid\n-            ):\n-                print(user_uid, flush=True)\n-                await self.send_to_user(user_uid, message)\n-        except Exception as ex:\n-            print(ex, flush=True)\n-        return True\n-\n-    async def delete(self, ws):\n-        for s in [sock for sock in self.sockets if sock.ws == ws]:\n-            await s.close()\n-            self.sockets.remove(s)\n+\tclass Socket:\n+\t\tdef __init__(A,ws,user):A.ws=ws;A.is_connected=_A;A.user=user\n+\t\tasync def send_json(A,data):\n+\t\t\tif not A.is_connected:return _B\n+\t\t\ttry:await A.ws.send_json(data)\n+\t\t\texcept Exception:A.is_connected=_B\n+\t\t\treturn A.is_connected\n+\t\tasync def close(A):\n+\t\t\tif not A.is_connected:return _A\n+\t\t\tawait A.ws.close();A.is_connected=_B;return _A\n+\tdef __init__(A,app):super().__init__(app);A.sockets=set();A.users={};A.subscriptions={}\n+\tasync def add(A,ws,user_uid):\n+\t\tB=user_uid;C=A.Socket(ws,await A.app.services.user.get(uid=B));A.sockets.add(C)\n+\t\tif not A.users.get(B):A.users[B]=set()\n+\t\tA.users[B].add(C)\n+\tasync def subscribe(A,ws,channel_uid,user_uid):\n+\t\tB=channel_uid\n+\t\tif B not in A.subscriptions:A.subscriptions[B]=set()\n+\t\tC=A.Socket(ws,await A.app.services.user.get(uid=user_uid));A.subscriptions[B].add(C)\n+\tasync def send_to_user(B,user_uid,message):\n+\t\tA=0\n+\t\tfor C in B.users.get(user_uid,[]):\n+\t\t\tif await C.send_json(message):A+=1\n+\t\treturn A\n+\tasync def broadcast(A,channel_uid,message):\n+\t\ttry:\n+\t\t\tasync for B in A.services.channel_member.get_user_uids(channel_uid):print(B,flush=_A);await A.send_to_user(B,message)\n+\t\texcept Exception as C:print(C,flush=_A)\n+\t\treturn _A\n+\tasync def delete(A,ws):\n+\t\tfor B in[A for A in A.sockets if A.ws==ws]:await B.close();A.sockets.remove(B)\n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 76e6d1c..6ece3fd 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,91 +1,53 @@\n+_B='color'\n+_A=True\n import pathlib\n-\n from snek.system import security\n from snek.system.service import BaseService\n-\n-\n class UserService(BaseService):\n-    mapper_name = \"user\"\n-\n-    async def get_by_username(self, username):\n-        return await self.get(username=username)\n-\n-    async def search(self, query, **kwargs):\n-        query = query.strip().lower()\n-        if not query:\n-            return []\n-        results = []\n-        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n-            results.append(result)\n-        return results\n-\n-    async def validate_login(self, username, password):\n-        model = await self.get(username=username)\n-        if not model:\n-            return False\n-        if not await security.verify(password, model[\"password\"]):\n-            return False\n-        return True\n-\n-    async def save(self, user):\n-        if not user[\"color\"]:\n-            user[\"color\"] = await self.services.util.random_light_hex_color()\n-        return await super().save(user)\n-\n-    async def authenticate(self, username, password):\n-        print(username, password, flush=True)\n-        success = await self.validate_login(username, password)\n-        print(success, flush=True)\n-        if not success:\n-            return None\n-\n-        model = await self.get(username=username, deleted_at=None)\n-        return model\n-\n-    def get_admin_uids(self):\n-        return self.mapper.get_admin_uids()\n-\n-    async def get_repository_path(self, user_uid):\n-        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-\n-    async def get_static_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n-        if not path.exists():\n-            return None\n-        return path\n-\n-\n-\n-    async def get_template_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n-        if not path.exists():\n-            return None\n-        return path\n-\n-    async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./drive/{user_uid}\")\n-        if not folder.exists():\n-            try:\n-                folder.mkdir(parents=True, exist_ok=True)\n-            except:\n-                pass\n-        return folder\n-\n-    async def register(self, email, username, password):\n-        if await self.exists(username=username):\n-            raise Exception(\"User already exists.\")\n-        model = await self.new()\n-        model[\"nick\"] = username\n-        model[\"color\"] = await self.services.util.random_light_hex_color()\n-        model.email.value = email\n-        model.username.value = username\n-        model.password.value = await security.hash(password)\n-        if await self.save(model):\n-            if model:\n-                channel = await self.services.channel.ensure_public_channel(\n-                    model[\"uid\"]\n-                )\n-                if not channel:\n-                    raise Exception(\"Failed to create public channel.\")\n-            return model\n-        raise Exception(f\"Failed to create user: {model.errors}.\")\n+\tmapper_name='user'\n+\tasync def get_by_username(A,username):return await A.get(username=username)\n+\tasync def search(C,query,**D):\n+\t\tA=query;A=A.strip().lower()\n+\t\tif not A:return[]\n+\t\tB=[]\n+\t\tasync for E in C.find(username={'ilike':'%'+A+'%'},**D):B.append(E)\n+\t\treturn B\n+\tasync def validate_login(C,username,password):\n+\t\tA=False;B=await C.get(username=username)\n+\t\tif not B:return A\n+\t\tif not await security.verify(password,B['password']):return A\n+\t\treturn _A\n+\tasync def save(B,user):\n+\t\tA=user\n+\t\tif not A[_B]:A[_B]=await B.services.util.random_light_hex_color()\n+\t\treturn await super().save(A)\n+\tasync def authenticate(B,username,password):\n+\t\tC=password;A=username;print(A,C,flush=_A);D=await B.validate_login(A,C);print(D,flush=_A)\n+\t\tif not D:return\n+\t\tE=await B.get(username=A,deleted_at=None);return E\n+\tdef get_admin_uids(A):return A.mapper.get_admin_uids()\n+\tasync def get_repository_path(A,user_uid):return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+\tasync def get_static_path(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+\t\tif not A.exists():return\n+\t\treturn A\n+\tasync def get_template_path(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+\t\tif not A.exists():return\n+\t\treturn A\n+\tasync def get_home_folder(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}\")\n+\t\tif not A.exists():\n+\t\t\ttry:A.mkdir(parents=_A,exist_ok=_A)\n+\t\t\texcept:pass\n+\t\treturn A\n+\tasync def register(B,email,username,password):\n+\t\tC=username\n+\t\tif await B.exists(username=C):raise Exception('User already exists.')\n+\t\tA=await B.new();A['nick']=C;A[_B]=await B.services.util.random_light_hex_color();A.email.value=email;A.username.value=C;A.password.value=await security.hash(password)\n+\t\tif await B.save(A):\n+\t\t\tif A:\n+\t\t\t\tD=await B.services.channel.ensure_public_channel(A['uid'])\n+\t\t\t\tif not D:raise Exception('Failed to create public channel.')\n+\t\t\treturn A\n+\t\traise Exception(f\"Failed to create user: {A.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 4d11fa8..da9136a 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,35 +1,15 @@\n+_A='user_property'\n import json\n-\n from snek.system.service import BaseService\n-\n-\n class UserPropertyService(BaseService):\n-    mapper_name = \"user_property\"\n-\n-    async def set(self, user_uid, name, value):\n-        self.mapper.db[\"user_property\"].upsert(\n-            {\n-                \"user_uid\": user_uid,\n-                \"name\": name,\n-                \"value\": json.dumps(value, default=str),\n-            },\n-            [\"user_uid\", \"name\"],\n-        )\n-\n-    async def get(self, user_uid, name):\n-        try:\n-            return json.loads(\n-                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n-            )\n-        except Exception as ex:\n-            print(ex)\n-            return None\n-\n-    async def search(self, query, **kwargs):\n-        query = query.strip().lower()\n-        if not query:\n-            raise []\n-        results = []\n-        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n-            results.append(result)\n-        return results\n+\tmapper_name=_A\n+\tasync def set(C,user_uid,name,value):A='name';B='user_uid';C.mapper.db[_A].upsert({B:user_uid,A:name,'value':json.dumps(value,default=str)},[B,A])\n+\tasync def get(B,user_uid,name):\n+\t\ttry:return json.loads((await super().get(user_uid=user_uid,name=name))['value'])\n+\t\texcept Exception as A:print(A);return\n+\tasync def search(C,query,**D):\n+\t\tA=query;A=A.strip().lower()\n+\t\tif not A:raise[]\n+\t\tB=[]\n+\t\tasync for E in C.find(name={'ilike':'%'+A+'%'},**D):B.append(E)\n+\t\treturn B\n\\ No newline at end of file\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex b620d9c..73dbec3 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,14 +1,4 @@\n import random\n-\n from snek.system.service import BaseService\n-\n-\n class UtilService(BaseService):\n-\n-    async def random_light_hex_color(self):\n-\n-        r = random.randint(128, 255)\n-        g = random.randint(128, 255)\n-        b = random.randint(128, 255)\n-\n\\ No newline at end of file\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex f8bfeb7..0f3a69f 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,489 +1,207 @@\n-import os\n-import aiohttp\n+_O='branches'\n+_N='message'\n+_M='author'\n+_L='Invalid JSON data'\n+_K='origin'\n+_J='Repository not found'\n+_I='main'\n+_H='repository'\n+_G='branch'\n+_F='.git'\n+_E=None\n+_D='user'\n+_C='repo_name'\n+_B='username'\n+_A='repository_path'\n+import os,aiohttp\n from aiohttp import web\n-import git\n-import shutil\n-import json\n-import tempfile\n-import asyncio\n-import logging\n-import base64\n-import pathlib\n-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n-logger = logging.getLogger('git_server')\n-\n+import git,shutil,json,tempfile,asyncio,logging,base64,pathlib\n+logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger=logging.getLogger('git_server')\n class GitApplication(web.Application):\n-    def __init__(self, parent=None):\n-        self.parent = parent\n-        super().__init__(client_max_size=1024*1024*1024*5)\n-        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n-        self.USERS = {\n-            'x': 'x',\n-            'bob': 'bobpass',\n-        }\n-        self.add_routes([\n-            web.post('/create/{repo_name}', self.create_repository),\n-            web.delete('/delete/{repo_name}', self.delete_repository),\n-            web.get('/clone/{repo_name}', self.clone_repository),\n-            web.post('/push/{repo_name}', self.push_repository),\n-            web.post('/pull/{repo_name}', self.pull_repository),\n-            web.get('/status/{repo_name}', self.status_repository),\n-            web.get('/list', self.list_repositories),\n-            web.get('/branches/{repo_name}', self.list_branches),\n-            web.post('/branches/{repo_name}', self.create_branch),\n-            web.get('/log/{repo_name}', self.commit_log),\n-            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n-            web.get('/{path:.+}/info/refs', self.git_smart_http),\n-            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n-            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n-            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n-            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n-            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n-        ])\n-\n-\n-    async def check_basic_auth(self, request):\n-        auth_header = request.headers.get(\"Authorization\", \"\")\n-        if not auth_header.startswith(\"Basic \"):\n-            return None,None\n-        encoded_creds = auth_header.split(\"Basic \")[1]\n-        decoded_creds = base64.b64decode(encoded_creds).decode()\n-        username, password = decoded_creds.split(\":\", 1)\n-        request[\"user\"] = await self.parent.services.user.authenticate(\n-            username=username, password=password\n-        )\n-        if not request[\"user\"]:\n-            return None,None\n-        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n-            request[\"user\"][\"uid\"]\n-        )\n-\n-        return request[\"user\"]['username'],request[\"repository_path\"]\n-\n-\n-    @staticmethod\n-    def require_auth(handler):\n-        async def wrapped(self, request, *args, **kwargs):\n-            username, repository_path = await self.check_basic_auth(request)\n-            if not username or not repository_path:\n-                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n-            request['username'] = username\n-            request['repository_path'] = repository_path\n-            return await handler(self, request, *args, **kwargs)\n-        return wrapped\n-\n-    def repo_path(self, repository_path, repo_name):\n-        return repository_path.joinpath(repo_name + '.git')\n-\n-    def check_repo_exists(self, repository_path, repo_name):\n-        repo_dir = self.repo_path(repository_path, repo_name)\n-        if not os.path.exists(repo_dir):\n-            return web.Response(text=\"Repository not found\", status=404)\n-        return None\n-\n-    @require_auth\n-    async def create_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        if not repo_name or '/' in repo_name or '..' in repo_name:\n-            return web.Response(text=\"Invalid repository name\", status=400)\n-        repo_dir = self.repo_path(repository_path, repo_name)\n-        if os.path.exists(repo_dir):\n-            return web.Response(text=\"Repository already exists\", status=400)\n-        try:\n-            git.Repo.init(repo_dir, bare=True)\n-            logger.info(f\"Created repository: {repo_name} for user {username}\")\n-            return web.Response(text=f\"Created repository {repo_name}\")\n-        except Exception as e:\n-            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n-            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def delete_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            shutil.rmtree(self.repo_path(repository_path, repo_name))\n-            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n-            return web.Response(text=f\"Deleted repository {repo_name}\")\n-        except Exception as e:\n-            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n-            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def clone_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        host = request.host\n-        response_data = {\n-            \"repository\": repo_name,\n-            \"clone_command\": f\"git clone {clone_url}\",\n-            \"clone_url\": clone_url\n-        }\n-        return web.json_response(response_data)\n-\n-    @require_auth\n-    async def push_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            return web.Response(text=\"Invalid JSON data\", status=400)\n-        commit_message = data.get('commit_message', 'Update from server')\n-        branch = data.get('branch', 'main')\n-        changes = data.get('changes', [])\n-        if not changes:\n-            return web.Response(text=\"No changes provided\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-            for change in changes:\n-                file_path = os.path.join(temp_dir, change.get('file', ''))\n-                content = change.get('content', '')\n-                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n-                with open(file_path, 'w') as f:\n-                    f.write(content)\n-            temp_repo.git.add(A=True)\n-            if not temp_repo.config_reader().has_section('user'):\n-                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n-                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n-            temp_repo.index.commit(commit_message)\n-            origin = temp_repo.remote('origin')\n-            origin.push(refspec=f\"{branch}:{branch}\")\n-        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n-        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n-\n-    @require_auth\n-    async def pull_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            data = {}\n-        remote_url = data.get('remote_url')\n-        branch = data.get('branch', 'main')\n-        if not remote_url:\n-            return web.Response(text=\"Remote URL is required\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                remote_name = \"pull_source\"\n-                try:\n-                    remote = local_repo.create_remote(remote_name, remote_url)\n-                except git.GitCommandError:\n-                    remote = local_repo.remote(remote_name)\n-                    remote.set_url(remote_url)\n-                remote.fetch()\n-                local_repo.git.merge(f\"{remote_name}/{branch}\")\n-                origin = local_repo.remote('origin')\n-                origin.push()\n-                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n-                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n-            except Exception as e:\n-                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def status_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                branches = [b.name for b in temp_repo.branches]\n-                active_branch = temp_repo.active_branch.name\n-                commits = []\n-                for commit in list(temp_repo.iter_commits(max_count=5)):\n-                    commits.append({\n-                        \"id\": commit.hexsha,\n-                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n-                        \"date\": commit.committed_datetime.isoformat(),\n-                        \"message\": commit.message\n-                    })\n-                files = []\n-                for root, dirs, filenames in os.walk(temp_dir):\n-                    if '.git' in root:\n-                        continue\n-                    for filename in filenames:\n-                        full_path = os.path.join(root, filename)\n-                        rel_path = os.path.relpath(full_path, temp_dir)\n-                        files.append(rel_path)\n-                status_info = {\n-                    \"repository\": repo_name,\n-                    \"branches\": branches,\n-                    \"active_branch\": active_branch,\n-                    \"recent_commits\": commits,\n-                    \"files\": files\n-                }\n-                return web.json_response(status_info)\n-            except Exception as e:\n-                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def list_repositories(self, request):\n-        username = request['username']\n-        try:\n-            repos = []\n-            user_dir = self.REPO_DIR\n-            if os.path.exists(user_dir):\n-                for item in os.listdir(user_dir):\n-                    item_path = os.path.join(user_dir, item)\n-                    if os.path.isdir(item_path) and item.endswith('.git'):\n-                        repos.append(item[:-4])\n-            if request.query.get('format') == 'json':\n-                return web.json_response({\"repositories\": repos})\n-            else:\n-                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n-        except Exception as e:\n-            logger.error(f\"Error listing repositories: {str(e)}\")\n-            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def list_branches(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-            branches = [b.name for b in temp_repo.branches]\n-            return web.json_response({\"branches\": branches})\n-\n-    @require_auth\n-    async def create_branch(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            return web.Response(text=\"Invalid JSON data\", status=400)\n-        branch_name = data.get('branch_name')\n-        start_point = data.get('start_point', 'HEAD')\n-        if not branch_name:\n-            return web.Response(text=\"Branch name is required\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                temp_repo.git.branch(branch_name, start_point)\n-                temp_repo.git.push('origin', branch_name)\n-                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n-                return web.Response(text=f\"Created branch {branch_name}\")\n-            except Exception as e:\n-                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def commit_log(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            limit = int(request.query.get('limit', 10))\n-            branch = request.query.get('branch', 'main')\n-        except ValueError:\n-            return web.Response(text=\"Invalid limit parameter\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                commits = []\n-                try:\n-                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n-                        commits.append({\n-                            \"id\": commit.hexsha,\n-                            \"short_id\": commit.hexsha[:7],\n-                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n-                            \"date\": commit.committed_datetime.isoformat(),\n-                            \"message\": commit.message.strip()\n-                        })\n-                except git.GitCommandError as e:\n-                    if \"unknown revision or path\" in str(e):\n-                        commits = []\n-                    else:\n-                        raise\n-                return web.json_response({\n-                    \"repository\": repo_name,\n-                    \"branch\": branch,\n-                    \"commits\": commits\n-                })\n-            except Exception as e:\n-                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def file_content(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        file_path = request.match_info.get('file_path', '')\n-        branch = request.query.get('branch', 'main')\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                try:\n-                    temp_repo.git.checkout(branch)\n-                except git.GitCommandError:\n-                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n-                file_full_path = os.path.join(temp_dir, file_path)\n-                if not os.path.exists(file_full_path):\n-                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n-                if os.path.isdir(file_full_path):\n-                    files = os.listdir(file_full_path)\n-                    return web.json_response({\n-                        \"repository\": repo_name,\n-                        \"path\": file_path,\n-                        \"type\": \"directory\",\n-                        \"contents\": files\n-                    })\n-                else:\n-                    try:\n-                        with open(file_full_path, 'r') as f:\n-                            content = f.read()\n-                        return web.Response(text=content)\n-                    except UnicodeDecodeError:\n-                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n-            except Exception as e:\n-                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def git_smart_http(self, request):\n-        username = request['username']\n-        repository_path = request['repository_path']\n-        path = request.path\n-        async def get_repository_path():\n-            req_path = path.lstrip('/')\n-            if req_path.endswith('/info/refs'):\n-                repo_name = req_path[:-len('/info/refs')]\n-            elif req_path.endswith('/git-upload-pack'):\n-                repo_name = req_path[:-len('/git-upload-pack')]\n-            elif req_path.endswith('/git-receive-pack'):\n-                repo_name = req_path[:-len('/git-receive-pack')]\n-            else:\n-                repo_name = req_path\n-            if repo_name.endswith('.git'):\n-                repo_name = repo_name[:-4]\n-            repo_name = repo_name[4:]\n-            repo_dir = repository_path.joinpath(repo_name + \".git\")\n-            logger.info(f\"Resolved repo path: {repo_dir}\")\n-            return repo_dir \n-        async def handle_info_refs(service):\n-            repo_path = await get_repository_path()\n-        \n-            logger.info(f\"handle_info_refs: {repo_path}\")\n-            if not os.path.exists(repo_path):\n-                return web.Response(text=\"Repository not found\", status=404)\n-            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n-            try:\n-                process = await asyncio.create_subprocess_exec(\n-                    *cmd,\n-                    stdout=asyncio.subprocess.PIPE,\n-                    stderr=asyncio.subprocess.PIPE\n-                )\n-                stdout, stderr = await process.communicate()\n-                if process.returncode != 0:\n-                    logger.error(f\"Git command failed: {stderr.decode()}\")\n-                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n-                response = web.StreamResponse(\n-                    status=200,\n-                    reason='OK',\n-                    headers={\n-                        'Content-Type': f'application/x-{service}-advertisement',\n-                        'Cache-Control': 'no-cache'\n-                    }\n-                )\n-                await response.prepare(request)\n-                length = len(packet) + 4\n-                header = f\"{length:04x}\"\n-                await response.write(f\"{header}{packet}0000\".encode())\n-                await response.write(stdout)\n-                return response\n-            except Exception as e:\n-                logger.error(f\"Error handling info/refs: {str(e)}\")\n-                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n-        async def handle_service_rpc(service):\n-            repo_path = await get_repository_path()\n-            logger.info(f\"handle_service_rpc: {repo_path}\")\n-            if not os.path.exists(repo_path):\n-                return web.Response(text=\"Repository not found\", status=404)\n-            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n-                return web.Response(text=\"Invalid Content-Type\", status=403)\n-            body = await request.read()\n-            cmd = [service, '--stateless-rpc', str(repo_path)]\n-            try:\n-                process = await asyncio.create_subprocess_exec(\n-                    *cmd,\n-                    stdin=asyncio.subprocess.PIPE,\n-                    stdout=asyncio.subprocess.PIPE,\n-                    stderr=asyncio.subprocess.PIPE\n-                )\n-                stdout, stderr = await process.communicate(input=body)\n-                if process.returncode != 0:\n-                    logger.error(f\"Git command failed: {stderr.decode()}\")\n-                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n-                return web.Response(\n-                    body=stdout,\n-                    content_type=f'application/x-{service}-result'\n-                )\n-            except Exception as e:\n-                logger.error(f\"Error handling service RPC: {str(e)}\")\n-                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n-        if request.method == 'GET' and path.endswith('/info/refs'):\n-            service = request.query.get('service')\n-            if service in ('git-upload-pack', 'git-receive-pack'):\n-                return await handle_info_refs(service)\n-            else:\n-                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n-        elif request.method == 'POST' and '/git-upload-pack' in path:\n-            return await handle_service_rpc('git-upload-pack')\n-        elif request.method == 'POST' and '/git-receive-pack' in path:\n-            return await handle_service_rpc('git-receive-pack')\n-        return web.Response(text=\"Not found\", status=404)\n-\n-if __name__ == '__main__':\n-    try:\n-        import uvloop\n-        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-        logger.info(\"Using uvloop for improved performance\")\n-    except ImportError:\n-        logger.info(\"uvloop not available, using standard event loop\")\n-    app = GitApplication()\n-    logger.info(\"Starting Git server on port 8080\")\n-    web.run_app(app, port=8080)\n+\tdef __init__(A,parent=_E):B='/branches/{repo_name}';A.parent=parent;super().__init__(client_max_size=5368709120);A.REPO_DIR='drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545';A.USERS={'x':'x','bob':'bobpass'};A.add_routes([web.post('/create/{repo_name}',A.create_repository),web.delete('/delete/{repo_name}',A.delete_repository),web.get('/clone/{repo_name}',A.clone_repository),web.post('/push/{repo_name}',A.push_repository),web.post('/pull/{repo_name}',A.pull_repository),web.get('/status/{repo_name}',A.status_repository),web.get('/list',A.list_repositories),web.get(B,A.list_branches),web.post(B,A.create_branch),web.get('/log/{repo_name}',A.commit_log),web.get('/file/{repo_name}/{file_path:.*}',A.file_content),web.get('/{path:.+}/info/refs',A.git_smart_http),web.post('/{path:.+}/git-upload-pack',A.git_smart_http),web.post('/{path:.+}/git-receive-pack',A.git_smart_http),web.get('/{repo_name}.git/info/refs',A.git_smart_http),web.post('/{repo_name}.git/git-upload-pack',A.git_smart_http),web.post('/{repo_name}.git/git-receive-pack',A.git_smart_http)])\n+\tasync def check_basic_auth(B,request):\n+\t\tC='Basic ';A=request;D=A.headers.get('Authorization','')\n+\t\tif not D.startswith(C):return _E,_E\n+\t\tE=D.split(C)[1];F=base64.b64decode(E).decode();G,H=F.split(':',1);A[_D]=await B.parent.services.user.authenticate(username=G,password=H)\n+\t\tif not A[_D]:return _E,_E\n+\t\tA[_A]=await B.parent.services.user.get_repository_path(A[_D]['uid']);return A[_D][_B],A[_A]\n+\t@staticmethod\n+\tdef require_auth(handler):\n+\t\tasync def A(self,request,*D,**E):\n+\t\t\tA=request;B,C=await self.check_basic_auth(A)\n+\t\t\tif not B or not C:return web.Response(status=401,headers={'WWW-Authenticate':'Basic'},text='Authentication required')\n+\t\t\tA[_B]=B;A[_A]=C;return await handler(self,A,*D,**E)\n+\t\treturn A\n+\tdef repo_path(A,repository_path,repo_name):return repository_path.joinpath(repo_name+_F)\n+\tdef check_repo_exists(A,repository_path,repo_name):\n+\t\tB=A.repo_path(repository_path,repo_name)\n+\t\tif not os.path.exists(B):return web.Response(text=_J,status=404)\n+\t@require_auth\n+\tasync def create_repository(self,request):\n+\t\tB=request;E=B[_B];A=B.match_info[_C];F=B[_A]\n+\t\tif not A or'/'in A or'..'in A:return web.Response(text='Invalid repository name',status=400)\n+\t\tC=self.repo_path(F,A)\n+\t\tif os.path.exists(C):return web.Response(text='Repository already exists',status=400)\n+\t\ttry:git.Repo.init(C,bare=True);logger.info(f\"Created repository: {A} for user {E}\");return web.Response(text=f\"Created repository {A}\")\n+\t\texcept Exception as D:logger.error(f\"Error creating repository {A}: {str(D)}\");return web.Response(text=f\"Error creating repository: {str(D)}\",status=500)\n+\t@require_auth\n+\tasync def delete_repository(self,request):\n+\t\tB=request;F=B[_B];A=B.match_info[_C];C=B[_A];D=self.check_repo_exists(C,A)\n+\t\tif D:return D\n+\t\ttry:shutil.rmtree(self.repo_path(C,A));logger.info(f\"Deleted repository: {A} for user {F}\");return web.Response(text=f\"Deleted repository {A}\")\n+\t\texcept Exception as E:logger.error(f\"Error deleting repository {A}: {str(E)}\");return web.Response(text=f\"Error deleting repository: {str(E)}\",status=500)\n+\t@require_auth\n+\tasync def clone_repository(self,request):\n+\t\tA=request;H=A[_B];B=A.match_info[_C];E=A[_A];C=self.check_repo_exists(E,B)\n+\t\tif C:return C\n+\t@require_auth\n+\tasync def push_repository(self,request):\n+\t\tB=request;L=B[_B];C=B.match_info[_C];E=B[_A];F=self.check_repo_exists(E,C)\n+\t\tif F:return F\n+\t\ttry:D=await B.json()\n+\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n+\t\tM=D.get('commit_message','Update from server');G=D.get(_G,_I);H=D.get('changes',[])\n+\t\tif not H:return web.Response(text='No changes provided',status=400)\n+\t\twith tempfile.TemporaryDirectory()as I:\n+\t\t\tA=git.Repo.clone_from(self.repo_path(E,C),I)\n+\t\t\tfor J in H:\n+\t\t\t\tK=os.path.join(I,J.get('file',''));N=J.get('content','');os.makedirs(os.path.dirname(K),exist_ok=True)\n+\t\t\t\twith open(K,'w')as O:O.write(N)\n+\t\t\tA.git.add(A=True)\n+\t\t\tif not A.config_reader().has_section(_D):A.config_writer().set_value(_D,'name','Git Server').release();A.config_writer().set_value(_D,'email','git@server.local').release()\n+\t\t\tA.index.commit(M);P=A.remote(_K);P.push(refspec=f\"{G}:{G}\")\n+\t\tlogger.info(f\"Pushed to repository: {C} for user {L}\");return web.Response(text=f\"Successfully pushed changes to {C}\")\n+\t@require_auth\n+\tasync def pull_repository(self,request):\n+\t\tC=request;K=C[_B];A=C.match_info[_C];H=C[_A];I=self.check_repo_exists(H,A)\n+\t\tif I:return I\n+\t\ttry:E=await C.json()\n+\t\texcept json.JSONDecodeError:E={}\n+\t\tB=E.get('remote_url');L=E.get(_G,_I)\n+\t\tif not B:return web.Response(text='Remote URL is required',status=400)\n+\t\twith tempfile.TemporaryDirectory()as M:\n+\t\t\ttry:\n+\t\t\t\tD=git.Repo.clone_from(self.repo_path(H,A),M);F='pull_source'\n+\t\t\t\ttry:G=D.create_remote(F,B)\n+\t\t\t\texcept git.GitCommandError:G=D.remote(F);G.set_url(B)\n+\t\t\t\tG.fetch();D.git.merge(f\"{F}/{L}\");N=D.remote(_K);N.push();logger.info(f\"Pulled to repository {A} from {B} for user {K}\");return web.Response(text=f\"Successfully pulled changes from {B} to {A}\")\n+\t\t\texcept Exception as J:logger.error(f\"Error pulling to {A}: {str(J)}\");return web.Response(text=f\"Error pulling changes: {str(J)}\",status=500)\n+\t@require_auth\n+\tasync def status_repository(self,request):\n+\t\tC=request;S=C[_B];B=C.match_info[_C];F=C[_A];G=self.check_repo_exists(F,B)\n+\t\tif G:return G\n+\t\twith tempfile.TemporaryDirectory()as D:\n+\t\t\ttry:\n+\t\t\t\tE=git.Repo.clone_from(self.repo_path(F,B),D);L=[A.name for A in E.branches];M=E.active_branch.name;H=[]\n+\t\t\t\tfor A in list(E.iter_commits(max_count=5)):H.append({'id':A.hexsha,_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message})\n+\t\t\t\tI=[]\n+\t\t\t\tfor(J,T,N)in os.walk(D):\n+\t\t\t\t\tif _F in J:continue\n+\t\t\t\t\tfor O in N:P=os.path.join(J,O);Q=os.path.relpath(P,D);I.append(Q)\n+\t\t\t\tR={_H:B,_O:L,'active_branch':M,'recent_commits':H,'files':I};return web.json_response(R)\n+\t\t\texcept Exception as K:logger.error(f\"Error getting status for {B}: {str(K)}\");return web.Response(text=f\"Error getting repository status: {str(K)}\",status=500)\n+\t@require_auth\n+\tasync def list_repositories(self,request):\n+\t\tD=request;G=D[_B]\n+\t\ttry:\n+\t\t\tA=[];B=self.REPO_DIR\n+\t\t\tif os.path.exists(B):\n+\t\t\t\tfor C in os.listdir(B):\n+\t\t\t\t\tF=os.path.join(B,C)\n+\t\t\t\t\tif os.path.isdir(F)and C.endswith(_F):A.append(C[:-4])\n+\t\t\tif D.query.get('format')=='json':return web.json_response({'repositories':A})\n+\t\t\telse:return web.Response(text='\\n'.join(A)if A else'No repositories found')\n+\t\texcept Exception as E:logger.error(f\"Error listing repositories: {str(E)}\");return web.Response(text=f\"Error listing repositories: {str(E)}\",status=500)\n+\t@require_auth\n+\tasync def list_branches(self,request):\n+\t\tA=request;H=A[_B];B=A.match_info[_C];C=A[_A];D=self.check_repo_exists(C,B)\n+\t\tif D:return D\n+\t\twith tempfile.TemporaryDirectory()as E:F=git.Repo.clone_from(self.repo_path(C,B),E);G=[A.name for A in F.branches];return web.json_response({_O:G})\n+\t@require_auth\n+\tasync def create_branch(self,request):\n+\t\tB=request;I=B[_B];C=B.match_info[_C];D=B[_A];E=self.check_repo_exists(D,C)\n+\t\tif E:return E\n+\t\ttry:F=await B.json()\n+\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n+\t\tA=F.get('branch_name');J=F.get('start_point','HEAD')\n+\t\tif not A:return web.Response(text='Branch name is required',status=400)\n+\t\twith tempfile.TemporaryDirectory()as K:\n+\t\t\ttry:G=git.Repo.clone_from(self.repo_path(D,C),K);G.git.branch(A,J);G.git.push(_K,A);logger.info(f\"Created branch {A} in repository {C} for user {I}\");return web.Response(text=f\"Created branch {A}\")\n+\t\t\texcept Exception as H:logger.error(f\"Error creating branch {A} in {C}: {str(H)}\");return web.Response(text=f\"Error creating branch: {str(H)}\",status=500)\n+\t@require_auth\n+\tasync def commit_log(self,request):\n+\t\tB=request;L=B[_B];C=B.match_info[_C];F=B[_A];G=self.check_repo_exists(F,C)\n+\t\tif G:return G\n+\t\ttry:I=int(B.query.get('limit',10));H=B.query.get(_G,_I)\n+\t\texcept ValueError:return web.Response(text='Invalid limit parameter',status=400)\n+\t\twith tempfile.TemporaryDirectory()as J:\n+\t\t\ttry:\n+\t\t\t\tK=git.Repo.clone_from(self.repo_path(F,C),J);E=[]\n+\t\t\t\ttry:\n+\t\t\t\t\tfor A in list(K.iter_commits(H,max_count=I)):E.append({'id':A.hexsha,'short_id':A.hexsha[:7],_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message.strip()})\n+\t\t\t\texcept git.GitCommandError as D:\n+\t\t\t\t\tif'unknown revision or path'in str(D):E=[]\n+\t\t\t\t\telse:raise\n+\t\t\t\treturn web.json_response({_H:C,_G:H,'commits':E})\n+\t\t\texcept Exception as D:logger.error(f\"Error getting commit log for {C}: {str(D)}\");return web.Response(text=f\"Error getting commit log: {str(D)}\",status=500)\n+\t@require_auth\n+\tasync def file_content(self,request):\n+\t\tA=request;N=A[_B];B=A.match_info[_C];C=A.match_info.get('file_path','');E=A.query.get(_G,_I);F=A[_A];G=self.check_repo_exists(F,B)\n+\t\tif G:return G\n+\t\twith tempfile.TemporaryDirectory()as H:\n+\t\t\ttry:\n+\t\t\t\tJ=git.Repo.clone_from(self.repo_path(F,B),H)\n+\t\t\t\ttry:J.git.checkout(E)\n+\t\t\t\texcept git.GitCommandError:return web.Response(text=f\"Branch '{E}' not found\",status=404)\n+\t\t\t\tD=os.path.join(H,C)\n+\t\t\t\tif not os.path.exists(D):return web.Response(text=f\"File '{C}' not found\",status=404)\n+\t\t\t\tif os.path.isdir(D):K=os.listdir(D);return web.json_response({_H:B,'path':C,'type':'directory','contents':K})\n+\t\t\t\telse:\n+\t\t\t\t\ttry:\n+\t\t\t\t\t\twith open(D,'r')as L:M=L.read()\n+\t\t\t\t\t\treturn web.Response(text=M)\n+\t\t\t\t\texcept UnicodeDecodeError:return web.Response(text=f\"Cannot display binary file content for '{C}'\",status=400)\n+\t\t\texcept Exception as I:logger.error(f\"Error getting file content from {B}: {str(I)}\");return web.Response(text=f\"Error getting file content: {str(I)}\",status=500)\n+\t@require_auth\n+\tasync def git_smart_http(self,request):\n+\t\tB='POST';G='git-receive-pack';H='git-upload-pack';I='Content-Type';J='--stateless-rpc';D='/git-receive-pack';E='/git-upload-pack';F='/info/refs';A=request;P=A[_B];N=A[_A];C=A.path\n+\t\tasync def K():\n+\t\t\tB=C.lstrip('/')\n+\t\t\tif B.endswith(F):A=B[:-len(F)]\n+\t\t\telif B.endswith(E):A=B[:-len(E)]\n+\t\t\telif B.endswith(D):A=B[:-len(D)]\n+\t\t\telse:A=B\n+\t\t\tif A.endswith(_F):A=A[:-4]\n+\t\t\tA=A[4:];G=N.joinpath(A+_F);logger.info(f\"Resolved repo path: {G}\");return G\n+\t\tasync def O(service):\n+\t\t\tC=service;D=await K();logger.info(f\"handle_info_refs: {D}\")\n+\t\t\tif not os.path.exists(D):return web.Response(text=_J,status=404)\n+\t\t\tL=[C,J,'--advertise-refs',str(D)]\n+\t\t\ttry:\n+\t\t\t\tE=await asyncio.create_subprocess_exec(*L,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);M,F=await E.communicate()\n+\t\t\t\tif E.returncode!=0:logger.error(f\"Git command failed: {F.decode()}\");return web.Response(text=f\"Git error: {F.decode()}\",status=500)\n+\t\t\texcept Exception as H:logger.error(f\"Error handling info/refs: {str(H)}\");return web.Response(text=f\"Server error: {str(H)}\",status=500)\n+\t\tasync def L(service):\n+\t\t\tB=service;C=await K();logger.info(f\"handle_service_rpc: {C}\")\n+\t\t\tif not os.path.exists(C):return web.Response(text=_J,status=404)\n+\t\t\tif not A.headers.get(I)==f\"application/x-{B}-request\":return web.Response(text='Invalid Content-Type',status=403)\n+\t\t\tG=await A.read();H=[B,J,str(C)]\n+\t\t\ttry:\n+\t\t\t\tD=await asyncio.create_subprocess_exec(*H,stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);L,E=await D.communicate(input=G)\n+\t\t\t\tif D.returncode!=0:logger.error(f\"Git command failed: {E.decode()}\");return web.Response(text=f\"Git error: {E.decode()}\",status=500)\n+\t\t\t\treturn web.Response(body=L,content_type=f\"application/x-{B}-result\")\n+\t\t\texcept Exception as F:logger.error(f\"Error handling service RPC: {str(F)}\");return web.Response(text=f\"Server error: {str(F)}\",status=500)\n+\t\tif A.method=='GET'and C.endswith(F):\n+\t\t\tM=A.query.get('service')\n+\t\t\tif M in(H,G):return await O(M)\n+\t\t\telse:return web.Response(text='Smart HTTP requires service parameter',status=400)\n+\t\telif A.method==B and E in C:return await L(H)\n+\t\telif A.method==B and D in C:return await L(G)\n+\t\treturn web.Response(text='Not found',status=404)\n+if __name__=='__main__':\n+\ttry:import uvloop;asyncio.set_event_loop_policy(uvloop.EventLoopPolicy());logger.info('Using uvloop for improved performance')\n+\texcept ImportError:logger.info('uvloop not available, using standard event loop')\n+\tapp=GitApplication();logger.info('Starting Git server on port 8080');web.run_app(app,port=8080)\n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex eed888a..57e90a3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,144 +1,67 @@\n-import functools\n-import json\n-\n+_C='delete'\n+_B='set'\n+_A='get'\n+import functools,json\n from snek.system import security\n-\n-cache = functools.cache\n-\n-CACHE_MAX_ITEMS_DEFAULT = 5000\n-\n-\n+cache=functools.cache\n+CACHE_MAX_ITEMS_DEFAULT=5000\n class Cache:\n-    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n-        self.app = app\n-        self.cache = {}\n-        self.max_items = max_items\n-        self.stats = {}\n-        self.lru = []\n-        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n-\n-    async def get(self, args):\n-        await self.update_stat(args, \"get\")\n-        try:\n-            self.lru.pop(self.lru.index(args))\n-        except:\n-            return None\n-        self.lru.insert(0, args)\n-        while len(self.lru) > self.max_items:\n-            self.cache.pop(self.lru[-1])\n-            self.lru.pop()\n-        return self.cache[args]\n-\n-    async def get_stats(self):\n-        all_ = []\n-        for key in self.lru:\n-            all_.append(\n-                {\n-                    \"key\": key,\n-                    \"set\": self.stats[key][\"set\"],\n-                    \"get\": self.stats[key][\"get\"],\n-                    \"delete\": self.stats[key][\"delete\"],\n-                    \"value\": str(self.serialize(self.cache[key].record)),\n-                }\n-            )\n-        return all_\n-\n-    def serialize(self, obj):\n-        cpy = obj.copy()\n-        cpy.pop(\"created_at\", None)\n-        cpy.pop(\"deleted_at\", None)\n-        cpy.pop(\"email\", None)\n-        cpy.pop(\"password\", None)\n-        return cpy\n-\n-    async def update_stat(self, key, action):\n-        if key not in self.stats:\n-            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n-        self.stats[key][action] = self.stats[key][action] + 1\n-\n-    def json_default(self, value):\n-        try:\n-            return json.dumps(value.__dict__, default=str)\n-        except:\n-            return str(value)\n-\n-    async def create_cache_key(self, args, kwargs):\n-        return await security.hash(\n-            json.dumps(\n-                {\"args\": args, \"kwargs\": kwargs},\n-                sort_keys=True,\n-                default=self.json_default,\n-            )\n-        )\n-\n-    async def set(self, args, result):\n-        is_new = args not in self.cache\n-        self.cache[args] = result\n-        await self.update_stat(args, \"set\")\n-        try:\n-            self.lru.pop(self.lru.index(args))\n-        except (ValueError, IndexError):\n-            pass\n-        self.lru.insert(0, args)\n-\n-        while len(self.lru) > self.max_items:\n-            self.cache.pop(self.lru[-1])\n-            self.lru.pop()\n-\n-        if is_new:\n-            self.version += 1\n-\n-    async def delete(self, args):\n-        await self.update_stat(args, \"delete\")\n-        if args in self.cache:\n-            try:\n-                self.lru.pop(self.lru.index(args))\n-            except IndexError:\n-                pass\n-            del self.cache[args]\n-\n-    def async_cache(self, func):\n-        @functools.wraps(func)\n-        async def wrapper(*args, **kwargs):\n-            cache_key = await self.create_cache_key(args, kwargs)\n-            cached = await self.get(cache_key)\n-            if cached:\n-                return cached\n-            result = await func(*args, **kwargs)\n-            await self.set(cache_key, result)\n-            return result\n-\n-        return wrapper\n-\n-    def async_delete_cache(self, func):\n-        @functools.wraps(func)\n-        async def wrapper(*args, **kwargs):\n-            cache_key = await self.create_cache_key(args, kwargs)\n-            if cache_key in self.cache:\n-                try:\n-                    self.lru.pop(self.lru.index(cache_key))\n-                except IndexError:\n-                    pass\n-                del self.cache[cache_key]\n-            return await func(*args, **kwargs)\n-\n-        return wrapper\n-\n-\n+\tdef __init__(A,app,max_items=CACHE_MAX_ITEMS_DEFAULT):A.app=app;A.cache={};A.max_items=max_items;A.stats={};A.lru=[];A.version=15505\n+\tasync def get(A,args):\n+\t\tB=args;await A.update_stat(B,_A)\n+\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\texcept:return\n+\t\tA.lru.insert(0,B)\n+\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n+\t\treturn A.cache[B]\n+\tasync def get_stats(A):\n+\t\tC=[]\n+\t\tfor B in A.lru:C.append({'key':B,_B:A.stats[B][_B],_A:A.stats[B][_A],_C:A.stats[B][_C],'value':str(A.serialize(A.cache[B].record))})\n+\t\treturn C\n+\tdef serialize(C,obj):B=None;A=obj.copy();A.pop('created_at',B);A.pop('deleted_at',B);A.pop('email',B);A.pop('password',B);return A\n+\tasync def update_stat(A,key,action):\n+\t\tC=action;B=key\n+\t\tif B not in A.stats:A.stats[B]={_B:0,_A:0,_C:0}\n+\t\tA.stats[B][C]=A.stats[B][C]+1\n+\tdef json_default(B,value):\n+\t\tA=value\n+\t\ttry:return json.dumps(A.__dict__,default=str)\n+\t\texcept:return str(A)\n+\tasync def create_cache_key(A,args,kwargs):return await security.hash(json.dumps({'args':args,'kwargs':kwargs},sort_keys=True,default=A.json_default))\n+\tasync def set(A,args,result):\n+\t\tB=args;C=B not in A.cache;A.cache[B]=result;await A.update_stat(B,_B)\n+\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\texcept(ValueError,IndexError):pass\n+\t\tA.lru.insert(0,B)\n+\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n+\t\tif C:A.version+=1\n+\tasync def delete(A,args):\n+\t\tB=args;await A.update_stat(B,_C)\n+\t\tif B in A.cache:\n+\t\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\t\texcept IndexError:pass\n+\t\t\tdel A.cache[B]\n+\tdef async_cache(A,func):\n+\t\t@functools.wraps(func)\n+\t\tasync def B(*B,**C):\n+\t\t\tD=await A.create_cache_key(B,C);E=await A.get(D)\n+\t\t\tif E:return E\n+\t\t\tF=await func(*B,**C);await A.set(D,F);return F\n+\t\treturn B\n+\tdef async_delete_cache(A,func):\n+\t\t@functools.wraps(func)\n+\t\tasync def B(*C,**D):\n+\t\t\tB=await A.create_cache_key(C,D)\n+\t\t\tif B in A.cache:\n+\t\t\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\t\t\texcept IndexError:pass\n+\t\t\t\tdel A.cache[B]\n+\t\t\treturn await func(*C,**D)\n+\t\treturn B\n def async_cache(func):\n-    cache = {}\n-\n-    @functools.wraps(func)\n-    async def wrapper(*args):\n-        if args in cache:\n-            return cache[args]\n-        result = await func(*args)\n-        cache[args] = result\n-        return result\n-\n-    return wrapper\n+\tB={}\n+\t@functools.wraps(func)\n+\tasync def A(*A):\n+\t\tif A in B:return B[A]\n+\t\tC=await func(*A);B[A]=C;return C\n+\treturn A\n\\ No newline at end of file\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex f4cf2d3..0ec782b 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,120 +1,32 @@\n-\n-\n-\n-\n+_B='fields'\n+_A=None\n from snek.system import model\n-\n-\n class HTMLElement(model.ModelField):\n-    def __init__(\n-        self,\n-        id=None,\n-        tag=\"div\",\n-        name=None,\n-        html=None,\n-        class_name=None,\n-        text=None,\n-        *args,\n-        **kwargs,\n-    ):\n-        self.tag = tag\n-        self.text = text\n-        self.id = id\n-        self.class_name = class_name or name\n-        self.html = html\n-        super().__init__(name=name, *args, **kwargs)\n-\n-    async def to_json(self):\n-        result = await super().to_json()\n-        result[\"text\"] = self.text\n-        result[\"id\"] = self.id\n-        result[\"html\"] = self.html\n-        result[\"class_name\"] = self.class_name\n-        result[\"tag\"] = self.tag\n-        return result\n-\n-\n-class FormElement(HTMLElement):\n-    pass\n-\n-\n+\tdef __init__(A,id=_A,tag='div',name=_A,html=_A,class_name=_A,text=_A,*B,**C):A.tag=tag;A.text=text;A.id=id;A.class_name=class_name or name;A.html=html;super().__init__(*B,name=name,**C)\n+\tasync def to_json(B):A=await super().to_json();A['text']=B.text;A['id']=B.id;A['html']=B.html;A['class_name']=B.class_name;A['tag']=B.tag;return A\n+class FormElement(HTMLElement):0\n class FormInputElement(FormElement):\n-    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n-        super().__init__(tag=\"input\", *args, **kwargs)\n-        self.place_holder = place_holder\n-        self.type = type\n-\n-    async def to_json(self):\n-        data = await super().to_json()\n-        data[\"place_holder\"] = self.place_holder\n-        data[\"type\"] = self.type\n-        return data\n-\n-\n+\tdef __init__(A,type='text',place_holder=_A,*B,**C):super().__init__(*B,tag='input',**C);A.place_holder=place_holder;A.type=type\n+\tasync def to_json(B):A=await super().to_json();A['place_holder']=B.place_holder;A['type']=B.type;return A\n class FormButtonElement(FormElement):\n-    def __init__(self, tag=\"button\", *args, **kwargs):\n-        super().__init__(tag=tag, *args, **kwargs)\n-\n-\n+\tdef __init__(C,tag='button',*A,**B):super().__init__(*A,tag=tag,**B)\n class Form(model.BaseModel):\n-    @property\n-    def html_elements(self):\n-        return [element for element in self.fields if isinstance(element, HTMLElement)]\n-\n-    def set_user_data(self, data):\n-        return super().set_user_data(data.get(\"fields\"))\n-\n-    async def to_json(self, encode=False):\n-        elements = await super().to_json()\n-        html_elements = {}\n-        for element in elements.keys():\n-            if element == \"is_valid\":\n-                continue\n-            field = getattr(self, element)\n-            if isinstance(field, HTMLElement):\n-                try:\n-                    html_elements[element] = elements[element]\n-                except KeyError:\n-                    pass\n-\n-        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n-        return {\n-            \"fields\": html_elements,\n-            \"is_valid\": is_valid,\n-            \"errors\": await self.errors,\n-        }\n-\n-    @property\n-    async def errors(self):\n-        result = []\n-        for field in self.html_elements:\n-            result += await field.errors\n-        return result\n-\n-    @property\n-    async def is_valid(self):\n-        return False\n+\t@property\n+\tdef html_elements(self):return[A for A in self.fields if isinstance(A,HTMLElement)]\n+\tdef set_user_data(A,data):return super().set_user_data(data.get(_B))\n+\tasync def to_json(D,encode=False):\n+\t\tB='is_valid';E=await super().to_json();C={}\n+\t\tfor A in E.keys():\n+\t\t\tif A==B:continue\n+\t\t\tF=getattr(D,A)\n+\t\t\tif isinstance(F,HTMLElement):\n+\t\t\t\ttry:C[A]=E[A]\n+\t\t\t\texcept KeyError:pass\n+\t\tG=all(A[B]for A in C.values());return{_B:C,B:G,'errors':await D.errors}\n+\t@property\n+\tasync def errors(self):\n+\t\tA=[]\n+\t\tfor B in self.html_elements:A+=await B.errors\n+\t\treturn A\n+\t@property\n+\tasync def is_valid(self):return False\n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex a1e87a4..fa993d5 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,110 +1,44 @@\n-\n-\n-\n-\n-\n-import asyncio\n-import pathlib\n-import uuid\n-import zlib\n+import asyncio,pathlib,uuid,zlib\n from urllib.parse import urljoin\n-\n-import aiohttp\n-import imgkit\n+import aiohttp,imgkit\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n-\n-\n async def crc32(data):\n-    try:\n-        data = data.encode()\n-    except:\n-        pass\n-    return \"crc32\" + str(zlib.crc32(data))\n-\n-\n-async def get_file(name, suffix=\".cache\"):\n-    name = await crc32(name)\n-    path = pathlib.Path(\".\").joinpath(\"cache\")\n-    if not path.exists():\n-        path.mkdir(parents=True, exist_ok=True)\n-    return path.joinpath(name + suffix)\n-\n-\n-async def public_touch(name=None):\n-    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n-    path.open(\"wb\").close()\n-    return path\n-\n-\n+\tA=data\n+\ttry:A=A.encode()\n+\texcept:pass\n+\treturn'crc32'+str(zlib.crc32(A))\n+async def get_file(name,suffix='.cache'):\n+\tA=name;A=await crc32(A);B=pathlib.Path('.').joinpath('cache')\n+\tif not B.exists():B.mkdir(parents=True,exist_ok=True)\n+\treturn B.joinpath(A+suffix)\n+async def public_touch(name=None):A=pathlib.Path('.').joinpath(str(uuid.uuid4())+name);A.open('wb').close();return A\n async def create_site_photo(url):\n-    loop = asyncio.get_event_loop()\n-    if not url.startswith(\"https\"):\n-    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n-\n-    if output_path.exists():\n-        return output_path\n-    output_path.touch()\n-\n-    def make_photo():\n-        imgkit.from_url(url, output_path.absolute())\n-        return output_path\n-\n-    return await loop.run_in_executor(None, make_photo)\n-\n-\n-async def repair_links(base_url, html_content):\n-    soup = BeautifulSoup(html_content, \"html.parser\")\n-    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n-        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n-            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n-        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n-            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n-    return soup.prettify()\n-\n-\n-async def is_html_content(content: bytes):\n-    if not content:\n-        return False\n-    try:\n-        content = content.decode(errors=\"ignore\")\n-    except:\n-        pass\n-    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n-    content = content.lower()\n-    for mark in marks:\n-        if mark in content:\n-            return True\n-    return False\n-\n-\n+\tA=url;C=asyncio.get_event_loop()\n+\tB=await get_file('site-screenshot-'+A,'.png')\n+\tif B.exists():return B\n+\tB.touch()\n+\tdef D():imgkit.from_url(A,B.absolute());return B\n+\treturn await C.run_in_executor(None,D)\n+async def repair_links(base_url,html_content):\n+\tD='http';E=base_url;B='src';C='href';F=BeautifulSoup(html_content,'html.parser')\n+\tfor A in F.find_all(['a','img','link']):\n+\t\tif A.has_attr(C)and not A[C].startswith(D):A[C]=urljoin(E,A[C])\n+\t\tif A.has_attr(B)and not A[B].startswith(D):A[B]=urljoin(E,A[B])\n+\treturn F.prettify()\n+async def is_html_content(content):\n+\tB=False;A=content\n+\tif not A:return B\n+\ttry:A=A.decode(errors='ignore')\n+\texcept:pass\n+\tC=['<html','<img','<p','<span','<div'];A=A.lower()\n+\tfor D in C:\n+\t\tif D in A:return True\n+\treturn B\n @time_cache_async(120)\n async def get(url):\n-    async with aiohttp.ClientSession() as session:\n-        response = await session.get(url)\n-        content = await response.text()\n-        if await is_html_content(content):\n-            content = (await repair_links(url, content)).encode()\n-        return content\n+\tasync with aiohttp.ClientSession()as B:\n+\t\tC=await B.get(url);A=await C.text()\n+\t\tif await is_html_content(A):A=(await repair_links(url,A)).encode()\n+\t\treturn A\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 4a59024..beb313e 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,70 +1,37 @@\n-DEFAULT_LIMIT = 30\n+_A='uid'\n+DEFAULT_LIMIT=30\n import typing\n-\n from snek.system.model import BaseModel\n-\n-\n class BaseMapper:\n-\n-    model_class: BaseModel = None\n-    default_limit: int = DEFAULT_LIMIT\n-    table_name: str = None\n-\n-    def __init__(self, app):\n-        self.app = app\n-\n-        self.default_limit = self.__class__.default_limit\n-\n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    async def new(self):\n-        return self.model_class(mapper=self, app=self.app)\n-\n-    @property\n-    def table(self):\n-        return self.db[self.table_name]\n-\n-    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n-        if uid:\n-            kwargs[\"uid\"] = uid\n-        record = self.table.find_one(**kwargs)\n-        if not record:\n-            return None\n-        record = dict(record)\n-        model = await self.new()\n-        for key, value in record.items():\n-            model[key] = value\n-        return model\n-        return await self.model_class.from_record(mapper=self, record=record)\n-\n-    async def exists(self, **kwargs):\n-        return self.table.exists(**kwargs)\n-\n-    async def count(self, **kwargs) -> int:\n-        return self.table.count(**kwargs)\n-\n-    async def save(self, model: BaseModel) -> bool:\n-        if not model.record.get(\"uid\"):\n-            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n-        model.updated_at.update()\n-        return self.table.upsert(model.record, [\"uid\"])\n-\n-    async def find(self, **kwargs) -> typing.AsyncGenerator:\n-        if not kwargs.get(\"_limit\"):\n-            kwargs[\"_limit\"] = self.default_limit\n-        for record in self.table.find(**kwargs):\n-            model = await self.new()\n-            for key, value in record.items():\n-                model[key] = value\n-            yield model\n-\n-    async def query(self, sql, *args):\n-        for record in self.db.query(sql, *args):\n-            yield dict(record)\n-\n-    async def delete(self, **kwargs) -> int:\n-        if not kwargs or not isinstance(kwargs, dict):\n-            raise Exception(\"Can't execute delete with no filter.\")\n-        return self.table.delete(**kwargs)\n+\tmodel_class:BaseModel=None;default_limit:int=DEFAULT_LIMIT;table_name:str=None\n+\tdef __init__(A,app):A.app=app;A.default_limit=A.__class__.default_limit\n+\t@property\n+\tdef db(self):return self.app.db\n+\tasync def new(A):return A.model_class(mapper=A,app=A.app)\n+\t@property\n+\tdef table(self):return self.db[self.table_name]\n+\tasync def get(B,uid=None,**C):\n+\t\tif uid:C[_A]=uid\n+\t\tA=B.table.find_one(**C)\n+\t\tif not A:return\n+\t\tA=dict(A);D=await B.new()\n+\t\tfor(E,F)in A.items():D[E]=F\n+\t\treturn D;return await B.model_class.from_record(mapper=B,record=A)\n+\tasync def exists(A,**B):return A.table.exists(**B)\n+\tasync def count(A,**B):return A.table.count(**B)\n+\tasync def save(B,model):\n+\t\tA=model\n+\t\tif not A.record.get(_A):raise Exception(f\"Attempt to save without uid: {A.record}.\")\n+\t\tA.updated_at.update();return B.table.upsert(A.record,[_A])\n+\tasync def find(A,**B):\n+\t\tC='_limit'\n+\t\tif not B.get(C):B[C]=A.default_limit\n+\t\tfor E in A.table.find(**B):\n+\t\t\tD=await A.new()\n+\t\t\tfor(F,G)in E.items():D[F]=G\n+\t\t\tyield D\n+\tasync def query(A,sql,*B):\n+\t\tfor C in A.db.query(sql,*B):yield dict(C)\n+\tasync def delete(B,**A):\n+\t\tif not A or not isinstance(A,dict):raise Exception(\"Can't execute delete with no filter.\")\n+\t\treturn B.table.delete(**A)\n\\ No newline at end of file\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..b530fb8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,87 +1,35 @@\n-\n+_A=True\n from types import SimpleNamespace\n-\n from app.cache import time_cache_async\n-from mistune import HTMLRenderer, Markdown\n+from mistune import HTMLRenderer,Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n-\n-\n class MarkdownRenderer(HTMLRenderer):\n-\n-    _allow_harmful_protocols = True\n-\n-    def __init__(self, app, template):\n-        self.template = template\n-\n-        self.app = app\n-        self.env = self.app.jinja2_env\n-        formatter = html.HtmlFormatter()\n-        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n-\n-    def _escape(self, str):\n-\n-    def get_lexer(self, lang, default=\"bash\"):\n-        try:\n-            return get_lexer_by_name(lang, stripall=True)\n-        except:\n-            return get_lexer_by_name(default, stripall=True)\n-\n-    def block_code(self, code, lang=None, info=None):\n-        if not lang:\n-            lang = info\n-        if not lang:\n-            lang = \"bash\"\n-        lexer = self.get_lexer(lang)\n-        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        result = highlight(code, lexer, formatter)\n-        return result\n-\n-    def render(self):\n-        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n-        renderer = MarkdownRenderer(self.app, self.template)\n-        markdown = Markdown(renderer=renderer)\n-        return markdown(markdown_string)\n-\n-\n-def render_markdown_sync(app, markdown_string):\n-    renderer = MarkdownRenderer(app, None)\n-    markdown = Markdown(renderer=renderer)\n-    return markdown(markdown_string)\n-\n-\n+\t_allow_harmful_protocols=_A\n+\tdef __init__(A,app,template):A.template=template;A.app=app;A.env=A.app.jinja2_env;B=html.HtmlFormatter();A.env.globals['highlight_styles']=B.get_style_defs()\n+\tdef _escape(A,str):return str\n+\tdef get_lexer(A,lang,default='bash'):\n+\t\ttry:return get_lexer_by_name(lang,stripall=_A)\n+\t\texcept:return get_lexer_by_name(default,stripall=_A)\n+\tdef block_code(B,code,lang=None,info=None):\n+\t\tA=lang\n+\t\tif not A:A=info\n+\t\tif not A:A='bash'\n+\t\tC=B.get_lexer(A);D=html.HtmlFormatter(lineseparator='<br>');E=highlight(code,C,D);return E\n+\tdef render(A):B=A.app.template_path.joinpath(A.template).read_text();C=MarkdownRenderer(A.app,A.template);D=Markdown(renderer=C);return D(B)\n+def render_markdown_sync(app,markdown_string):A=MarkdownRenderer(app,None);B=Markdown(renderer=A);return B(markdown_string)\n @time_cache_async(120)\n-async def render_markdown(app, markdown_string):\n-    return render_markdown_sync(app, markdown_string)\n-\n-\n-from jinja2 import TemplateSyntaxError, nodes\n+async def render_markdown(app,markdown_string):return render_markdown_sync(app,markdown_string)\n+from jinja2 import TemplateSyntaxError,nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-\n-\n class MarkdownExtension(Extension):\n-    tags = {\"markdown\"}\n-\n-    def __init__(self, environment):\n-        self.app = SimpleNamespace(jinja2_env=environment)\n-        super(MarkdownExtension, self).__init__(environment)\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        return render_markdown_sync(self.app, caller())\n+\ttags={'markdown'}\n+\tdef __init__(A,environment):B=environment;A.app=SimpleNamespace(jinja2_env=B);super(MarkdownExtension,A).__init__(B)\n+\tdef parse(D,parser):\n+\t\tA=parser;E=next(A.stream).lineno;B=[Const('')];C=''\n+\t\ttry:B=[A.parse_expression()]\n+\t\texcept TemplateSyntaxError:C=A.parse_statements(['name:endmarkdown'],drop_needle=_A)\n+\t\treturn nodes.CallBlock(D.call_method('_to_html',B),[],[],C).set_lineno(E)\n+\tdef _to_html(A,md_file,caller):return render_markdown_sync(A.app,caller())\n\\ No newline at end of file\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 3a9a055..1437a3f 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,53 +1,21 @@\n-\n-\n-\n-\n+_D='Access-Control-Allow-Credentials'\n+_C='Access-Control-Allow-Headers'\n+_B='Access-Control-Allow-Methods'\n+_A='Access-Control-Allow-Origin'\n from aiohttp import web\n-\n-\n @web.middleware\n-async def no_cors_middleware(request, handler):\n-    response = await handler(request)\n-    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n-    return response\n-\n-\n+async def no_cors_middleware(request,handler):A=await handler(request);A.headers.pop(_A,None);return A\n @web.middleware\n-async def cors_allow_middleware(request, handler):\n-    response = await handler(request)\n-    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = (\n-        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n-    )\n-    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-    return response\n-\n-\n+async def cors_allow_middleware(request,handler):A=await handler(request);A.headers[_A]='*';A.headers[_B]='GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND';A.headers[_C]='*';A.headers[_D]='true';return A\n @web.middleware\n-async def auth_middleware(request, handler):\n-    request[\"user\"] = None\n-    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n-        request[\"user\"] = await request.app.services.user.get(\n-            uid=request.app.session.get(\"uid\")\n-        )\n-    return await handler(request)\n-\n-\n+async def auth_middleware(request,handler):\n+\tB='uid';C='user';A=request;A[C]=None\n+\tif A.session.get(B)and A.session.get('logged_in'):A[C]=await A.app.services.user.get(uid=A.app.session.get(B))\n+\treturn await handler(A)\n @web.middleware\n-async def cors_middleware(request, handler):\n-    if request.headers.get(\"Allow\"):\n-        return await handler(request)\n-\n-    response = await handler(request)\n-    if request.headers.get(\"Allow\"):\n-        return response\n-    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n-    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-    return response\n+async def cors_middleware(request,handler):\n+\tC='Allow';D=handler;B=request\n+\tif B.headers.get(C):return await D(B)\n+\tA=await D(B)\n+\tif B.headers.get(C):return A\n+\tA.headers[_A]='*';A.headers[_B]='GET, POST, PUT, DELETE, OPTIONS';A.headers[_C]='*';A.headers[_D]='true';return A\n\\ No newline at end of file\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 9e9830d..b036329 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,377 +1,139 @@\n-\n-\n-\n-\n-\n-import copy\n-import json\n-import re\n-import uuid\n+_I='deleted_at'\n+_H='updated_at'\n+_G='created_at'\n+_F='is_valid'\n+_E='name'\n+_D=False\n+_C='value'\n+_B=True\n+_A=None\n+import copy,json,re,uuid\n from collections import OrderedDict\n-from datetime import datetime, timezone\n-\n-TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n-\n-\n-def now():\n-    return str(datetime.now(timezone.utc))\n-\n-\n-def add_attrs(**kwargs):\n-    def decorator(func):\n-        for key, value in kwargs.items():\n-            setattr(func, key, value)\n-        return func\n-\n-    return decorator\n-\n-\n-def validate_attrs(\n-    required=False, min_length=None, max_length=None, regex=None, **kwargs\n-):\n-    def decorator(func):\n-        return add_attrs(\n-            required=required,\n-            min_length=min_length,\n-            max_length=max_length,\n-            regex=regex,\n-            **kwargs,\n-        )(func)\n-\n-\n+from datetime import datetime,timezone\n+TIMESTAMP_REGEX='^\\\\d{4}-\\\\d{2}-\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{6}\\\\+\\\\d{2}:\\\\d{2}$'\n+def now():return str(datetime.now(timezone.utc))\n+def add_attrs(**A):\n+\tdef B(func):\n+\t\tfor(B,C)in A.items():setattr(func,B,C)\n+\t\treturn func\n+\treturn B\n+def validate_attrs(required=_D,min_length=_A,max_length=_A,regex=_A,**A):\n+\tdef B(func):return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**A)(func)\n class Validator:\n-    _index = 0\n-\n-    @property\n-    def value(self):\n-        return self._value\n-\n-    @value.setter\n-    def value(self, val):\n-        self._value = json.loads(json.dumps(val, default=str))\n-\n-    @property\n-    def initial_value(self):\n-        return self.value\n-\n-    def custom_validation(self):\n-        return True\n-\n-    def __init__(\n-        self,\n-        required=False,\n-        min_num=None,\n-        max_num=None,\n-        min_length=None,\n-        max_length=None,\n-        regex=None,\n-        value=None,\n-        kind=None,\n-        help_text=None,\n-        app=None,\n-        model=None,\n-        **kwargs,\n-    ):\n-        self.index = Validator._index\n-        Validator._index += 1\n-        self.app = app\n-        self.model = model\n-        self.required = required\n-        self.min_num = min_num\n-        self.max_num = max_num\n-        self.min_length = min_length\n-        self.max_length = max_length\n-        self.regex = regex\n-        self._value = None\n-        self.value = value\n-        self.kind = kind\n-        self.help_text = help_text\n-        self.__dict__.update(kwargs)\n-\n-    @property\n-    async def errors(self):\n-        error_list = []\n-        if self.value is None and self.required:\n-            error_list.append(\"Field is required.\")\n-            return error_list\n-\n-        if self.value is None:\n-            return error_list\n-\n-        if self.kind in [int, float]:\n-            if self.min_num is not None and self.value < self.min_num:\n-                error_list.append(f\"Field should be minimal {self.min_num}.\")\n-            if self.max_num is not None and self.value > self.max_num:\n-                error_list.append(f\"Field should be maximal {self.max_num}.\")\n-        if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(\n-                f\"Field should be minimal {self.min_length} characters long.\"\n-            )\n-        if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(\n-                f\"Field should be maximal {self.max_length} characters long.\"\n-            )\n-        if self.regex and self.value and not re.match(self.regex, self.value):\n-            error_list.append(\"Invalid value.\")\n-        if self.kind and not isinstance(self.value, self.kind):\n-            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n-        return error_list\n-\n-    async def validate(self):\n-        errors = await self.errors\n-        if errors:\n-            raise ValueError(f\"Errors: {errors}.\")\n-        return True\n-\n-    def __repr__(self):\n-        return str(self.to_json())\n-\n-    @property\n-    async def is_valid(self):\n-        try:\n-            await self.validate()\n-            return True\n-        except ValueError:\n-            return False\n-\n-    async def to_json(self):\n-        errors = await self.errors\n-        is_valid = await self.is_valid\n-        return {\n-            \"required\": self.required,\n-            \"min_num\": self.min_num,\n-            \"max_num\": self.max_num,\n-            \"min_length\": self.min_length,\n-            \"max_length\": self.max_length,\n-            \"regex\": self.regex,\n-            \"value\": self.value,\n-            \"kind\": str(self.kind),\n-            \"help_text\": self.help_text,\n-            \"errors\": errors,\n-            \"is_valid\": is_valid,\n-            \"index\": self.index,\n-        }\n-\n-\n+\t_index=0\n+\t@property\n+\tdef value(self):return self._value\n+\t@value.setter\n+\tdef value(self,val):self._value=json.loads(json.dumps(val,default=str))\n+\t@property\n+\tdef initial_value(self):return self.value\n+\tdef custom_validation(A):return _B\n+\tdef __init__(A,required=_D,min_num=_A,max_num=_A,min_length=_A,max_length=_A,regex=_A,value=_A,kind=_A,help_text=_A,app=_A,model=_A,**B):A.index=Validator._index;Validator._index+=1;A.app=app;A.model=model;A.required=required;A.min_num=min_num;A.max_num=max_num;A.min_length=min_length;A.max_length=max_length;A.regex=regex;A._value=_A;A.value=value;A.kind=kind;A.help_text=help_text;A.__dict__.update(B)\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=[]\n+\t\tif A.value is _A and A.required:B.append('Field is required.');return B\n+\t\tif A.value is _A:return B\n+\t\tif A.kind in[int,float]:\n+\t\t\tif A.min_num is not _A and A.value<A.min_num:B.append(f\"Field should be minimal {A.min_num}.\")\n+\t\t\tif A.max_num is not _A and A.value>A.max_num:B.append(f\"Field should be maximal {A.max_num}.\")\n+\t\tif A.min_length is not _A and len(A.value)<A.min_length:B.append(f\"Field should be minimal {A.min_length} characters long.\")\n+\t\tif A.max_length is not _A and len(A.value)>A.max_length:B.append(f\"Field should be maximal {A.max_length} characters long.\")\n+\t\tif A.regex and A.value and not re.match(A.regex,A.value):B.append('Invalid value.')\n+\t\tif A.kind and not isinstance(A.value,A.kind):B.append(f\"Invalid kind. It is supposed to be {A.kind}.\")\n+\t\treturn B\n+\tasync def validate(B):\n+\t\tA=await B.errors\n+\t\tif A:raise ValueError(f\"Errors: {A}.\")\n+\t\treturn _B\n+\tdef __repr__(A):return str(A.to_json())\n+\t@property\n+\tasync def is_valid(self):\n+\t\ttry:await self.validate();return _B\n+\t\texcept ValueError:return _D\n+\tasync def to_json(A):B=await A.errors;C=await A.is_valid;return{'required':A.required,'min_num':A.min_num,'max_num':A.max_num,'min_length':A.min_length,'max_length':A.max_length,'regex':A.regex,_C:A.value,'kind':str(A.kind),'help_text':A.help_text,'errors':B,_F:C,'index':A.index}\n class ModelField(Validator):\n-\n-    index = 1\n-\n-    def __init__(self, name=None, save=True, *args, **kwargs):\n-        self.name = name\n-        self.save = save\n-        super().__init__(*args, **kwargs)\n-\n-    async def to_json(self):\n-        result = await super().to_json()\n-        result[\"name\"] = self.name\n-        return result\n-\n-\n+\tindex=1\n+\tdef __init__(A,name=_A,save=_B,*B,**C):A.name=name;A.save=save;super().__init__(*B,**C)\n+\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;return A\n class CreatedField(ModelField):\n-\n-    @property\n-    def initial_value(self):\n-        return now()\n-\n-    def update(self):\n-        if not self.value:\n-            self.value = now()\n-\n-\n+\t@property\n+\tdef initial_value(self):return now()\n+\tdef update(A):\n+\t\tif not A.value:A.value=now()\n class UpdatedField(ModelField):\n-\n-    def update(self):\n-        self.value = now()\n-\n-\n+\tdef update(A):A.value=now()\n class DeletedField(ModelField):\n-\n-    def update(self):\n-        self.value = now()\n-\n-\n+\tdef update(A):A.value=now()\n class UUIDField(ModelField):\n-\n-    @property\n-    def value(self):\n-        return str(self._value)\n-\n-    @value.setter\n-    def value(self, val):\n-        self._value = str(val)\n-\n-    @property\n-    def initial_value(self):\n-        return str(uuid.uuid4())\n-\n-\n+\t@property\n+\tdef value(self):return str(self._value)\n+\t@value.setter\n+\tdef value(self,val):self._value=str(val)\n+\t@property\n+\tdef initial_value(self):return str(uuid.uuid4())\n class BaseModel:\n-\n-    uid = UUIDField(name=\"uid\", required=True)\n-    created_at = CreatedField(\n-        name=\"created_at\",\n-        required=True,\n-        regex=TIMESTAMP_REGEX,\n-        place_holder=\"Created at\",\n-    )\n-    updated_at = UpdatedField(\n-        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n-    )\n-    deleted_at = DeletedField(\n-        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n-    )\n-\n-    @classmethod\n-    async def from_record(cls, record, mapper):\n-        model = cls()\n-        model.mapper = mapper\n-        model.record = record\n-        return model\n-\n-    @property\n-    def mapper(self):\n-        return self._mapper\n-\n-    @mapper.setter\n-    def mapper(self, value):\n-        self._mapper = value\n-\n-    @property\n-    def record(self):\n-        return {key: field.value for key, field in self.fields.items()}\n-\n-    @record.setter\n-    def record(self, val):\n-        for key, value in val.items():\n-            field = self.fields.get(key)\n-            if not field:\n-                continue\n-            self[key] = value\n-        return self\n-\n-    def __init__(self, *args, **kwargs):\n-        self._mapper = kwargs.get(\"mapper\")\n-        self.app = kwargs.get(\"app\")\n-        self.fields = {}\n-        for key in dir(self.__class__):\n-            obj = getattr(self.__class__, key)\n-\n-            if isinstance(obj, Validator):\n-                self.__dict__[key] = copy.deepcopy(obj)\n-                self.__dict__[key].value = kwargs.pop(\n-                    key, self.__dict__[key].initial_value\n-                )\n-                self.fields[key] = self.__dict__[key]\n-                self.fields[key].model = self\n-                self.fields[key].app = kwargs.get(\"app\")\n-\n-    def __setitem__(self, key, value):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            obj.value = value\n-\n-    def __getattr__(self, key):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            return obj.value\n-        return obj\n-\n-    def set_user_data(self, data):\n-        for key, value in data.items():\n-            field = self.fields.get(key)\n-            if not field:\n-                continue\n-            if value.get(\"name\"):\n-                value = value.get(\"value\")\n-            field.value = value\n-\n-    @property\n-    async def is_valid(self):\n-        return all([await field.is_valid for field in self.fields.values()])\n-\n-    def __getitem__(self, key):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            return obj.value\n-\n-    def __setattr__(self, key, value):\n-        obj = getattr(self, key)\n-        if isinstance(obj, Validator):\n-            obj.value = value\n-        else:\n-            self.__dict__[key] = value\n-\n-    @property\n-    async def recordz(self):\n-        obj = await self.to_json()\n-        record = {}\n-        for key, value in obj.items():\n-            if not isinstance(value, dict) or \"value\" not in value:\n-                continue\n-            if getattr(self, key).save:\n-                record[key] = value.get(\"value\")\n-        return record\n-\n-    async def to_json(self, encode=False):\n-        model_data = OrderedDict(\n-            {\n-                \"uid\": self.uid.value,\n-                \"created_at\": self.created_at.value,\n-                \"updated_at\": self.updated_at.value,\n-                \"deleted_at\": self.deleted_at.value,\n-                \"is_valid\": await self.is_valid,\n-            }\n-        )\n-\n-        for key, value in self.fields.items():\n-            if key == \"record\":\n-                continue\n-            value = self.__dict__[key]\n-            if hasattr(value, \"value\"):\n-                model_data[key] = await value.to_json()\n-        if encode:\n-            return json.dumps(model_data, indent=2)\n-        return model_data\n-\n-\n+\tuid=UUIDField(name='uid',required=_B);created_at=CreatedField(name=_G,required=_B,regex=TIMESTAMP_REGEX,place_holder='Created at');updated_at=UpdatedField(name=_H,regex=TIMESTAMP_REGEX,place_holder='Updated at');deleted_at=DeletedField(name=_I,regex=TIMESTAMP_REGEX,place_holder='Deleted at')\n+\t@classmethod\n+\tasync def from_record(B,record,mapper):A=B();A.mapper=mapper;A.record=record;return A\n+\t@property\n+\tdef mapper(self):return self._mapper\n+\t@mapper.setter\n+\tdef mapper(self,value):self._mapper=value\n+\t@property\n+\tdef record(self):return{A:B.value for(A,B)in self.fields.items()}\n+\t@record.setter\n+\tdef record(self,val):\n+\t\tA=self\n+\t\tfor(B,C)in val.items():\n+\t\t\tD=A.fields.get(B)\n+\t\t\tif not D:continue\n+\t\t\tA[B]=C\n+\t\treturn A\n+\tdef __init__(A,*F,**C):\n+\t\tD='app';A._mapper=C.get('mapper');A.app=C.get(D);A.fields={}\n+\t\tfor B in dir(A.__class__):\n+\t\t\tE=getattr(A.__class__,B)\n+\t\t\tif isinstance(E,Validator):A.__dict__[B]=copy.deepcopy(E);A.__dict__[B].value=C.pop(B,A.__dict__[B].initial_value);A.fields[B]=A.__dict__[B];A.fields[B].model=A;A.fields[B].app=C.get(D)\n+\tdef __setitem__(B,key,value):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):A.value=value\n+\tdef __getattr__(B,key):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):return A.value\n+\t\treturn A\n+\tdef set_user_data(C,data):\n+\t\tfor(D,A)in data.items():\n+\t\t\tB=C.fields.get(D)\n+\t\t\tif not B:continue\n+\t\t\tif A.get(_E):A=A.get(_C)\n+\t\t\tB.value=A\n+\t@property\n+\tasync def is_valid(self):return all([await A.is_valid for A in self.fields.values()])\n+\tdef __getitem__(B,key):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):return A.value\n+\tdef __setattr__(A,key,value):\n+\t\tB=value;C=getattr(A,key)\n+\t\tif isinstance(C,Validator):C.value=B\n+\t\telse:A.__dict__[key]=B\n+\t@property\n+\tasync def recordz(self):\n+\t\tD=await self.to_json();B={}\n+\t\tfor(C,A)in D.items():\n+\t\t\tif not isinstance(A,dict)or _C not in A:continue\n+\t\t\tif getattr(self,C).save:B[C]=A.get(_C)\n+\t\treturn B\n+\tasync def to_json(A,encode=_D):\n+\t\tB=OrderedDict({'uid':A.uid.value,_G:A.created_at.value,_H:A.updated_at.value,_I:A.deleted_at.value,_F:await A.is_valid})\n+\t\tfor(C,D)in A.fields.items():\n+\t\t\tif C=='record':continue\n+\t\t\tD=A.__dict__[C]\n+\t\t\tif hasattr(D,_C):B[C]=await D.to_json()\n+\t\tif encode:return json.dumps(B,indent=2)\n+\t\treturn B\n class FormElement(ModelField):\n-\n-    def __init__(self, place_holder=None, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n-        self.place_holder = place_holder\n-\n-\n+\tdef __init__(A,place_holder=_A,*B,**C):super().__init__(*B,**C);A.place_holder=place_holder\n class FormElement(ModelField):\n-\n-    def __init__(self, place_holder=None, *args, **kwargs):\n-        self.place_holder = place_holder\n-        super().__init__(*args, **kwargs)\n-\n-    async def to_json(self):\n-        data = await super().to_json()\n-        data[\"name\"] = self.name\n-        data[\"place_holder\"] = self.place_holder\n-        return data\n+\tdef __init__(A,place_holder=_A,*B,**C):A.place_holder=place_holder;super().__init__(*B,**C)\n+\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;A['place_holder']=B.place_holder;return A\n\\ No newline at end of file\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex f91ec42..a36bb76 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,13 +1,7 @@\n class Object:\n-\n-    def __init__(self, *args, **kwargs):\n-        for arg in args:\n-            if isinstance(arg, dict):\n-                self.__dict__.update(arg)\n-        self.__dict__.update(kwargs)\n-\n-    def __getitem__(self, key):\n-        return self.__dict__[key]\n-\n-    def __setitem__(self, key, value):\n-        self.__dict__[key] = value\n+\tdef __init__(A,*C,**D):\n+\t\tfor B in C:\n+\t\t\tif isinstance(B,dict):A.__dict__.update(B)\n+\t\tA.__dict__.update(D)\n+\tdef __getitem__(A,key):return A.__dict__[key]\n+\tdef __setitem__(A,key,value):A.__dict__[key]=value\n\\ No newline at end of file\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex e0e5542..d196b30 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,46 +1,17 @@\n-import cProfile\n-import pstats\n-import sys\n-\n+import cProfile,pstats,sys\n from aiohttp import web\n-\n-profiler = None\n+profiler=None\n import io\n-\n-\n @web.middleware\n-async def profile_middleware(request, handler):\n-    global profiler\n-    if not profiler:\n-        profiler = cProfile.Profile()\n-    profiler.enable()\n-    response = await handler(request)\n-    profiler.disable()\n-    stats = pstats.Stats(profiler, stream=sys.stdout)\n-    stats.sort_stats(\"cumulative\")\n-    stats.print_stats()\n-    return response\n-\n-\n-async def profiler_handler(request):\n-    output = io.StringIO()\n-    stats = pstats.Stats(profiler, stream=output)\n-    sort_by = request.query.get(\"sort\", \"tot. percall\")\n-    stats.sort_stats(sort_by)\n-    stats.print_stats()\n-    return web.Response(text=output.getvalue())\n-\n-\n+async def profile_middleware(request,handler):\n+\tglobal profiler\n+\tif not profiler:profiler=cProfile.Profile()\n+\tprofiler.enable();B=await handler(request);profiler.disable();A=pstats.Stats(profiler,stream=sys.stdout);A.sort_stats('cumulative');A.print_stats();return B\n+async def profiler_handler(request):A=io.StringIO();B=pstats.Stats(profiler,stream=A);C=request.query.get('sort','tot. percall');B.sort_stats(C);B.print_stats();return web.Response(text=A.getvalue())\n class Profiler:\n-\n-    def __init__(self):\n-        global profiler\n-        if profiler is None:\n-            profiler = cProfile.Profile()\n-        self.profiler = profiler\n-\n-    async def __aenter__(self):\n-        self.profiler.enable()\n-\n-    async def __aexit__(self, *args, **kwargs):\n-        self.profiler.disable()\n+\tdef __init__(A):\n+\t\tglobal profiler\n+\t\tif profiler is None:profiler=cProfile.Profile()\n+\t\tA.profiler=profiler\n+\tasync def __aenter__(A):A.profiler.enable()\n+\tasync def __aexit__(A,*B,**C):A.profiler.disable()\n\\ No newline at end of file\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 43b61fe..8d5ced9 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,77 +1,24 @@\n-import hashlib\n-import uuid\n-\n-DEFAULT_SALT = \"snekker-de-snek-\"\n-DEFAULT_NS = \"snekker-de-snek-\"\n-\n-\n+_A='snekker-de-snek-'\n+import hashlib,uuid\n+DEFAULT_SALT=_A\n+DEFAULT_NS=_A\n class UIDNS:\n-    def __init__(self, name: str) -> None:\n-        \"\"\"Initialize UIDNS with a name.\"\"\"\n-        self.name = name\n-\n-    @property\n-    def bytes(self) -> bytes:\n-        \"\"\"Return the bytes representation of the name.\"\"\"\n-        return self.name.encode()\n-\n-\n-def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n-    \"\"\"Generate a UUID based on the provided value and namespace.\n-\n-    Args:\n-        value (str): The value to generate the UUID from. If None, a new UUID is created.\n-        ns (str): The namespace to use for UUID generation.\n-\n-    Returns:\n-        str: The generated UUID as a string.\n-    \"\"\"\n-    try:\n-        ns = ns.decode()\n-    except AttributeError:\n-        pass\n-    if not value:\n-        value = str(uuid.uuid4())\n-    try:\n-        value = value.decode()\n-    except AttributeError:\n-        pass\n-\n-    return str(uuid.uuid5(UIDNS(ns), value))\n-\n-\n-async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n-    \"\"\"Hash the given data with the specified salt using SHA-256.\n-\n-    Args:\n-        data (str): The data to hash.\n-        salt (str): The salt to use for hashing.\n-\n-    Returns:\n-        str: The hexadecimal representation of the hashed data.\n-    \"\"\"\n-    try:\n-        data = data.encode(errors=\"ignore\")\n-    except AttributeError:\n-        pass\n-    try:\n-        salt = salt.encode(errors=\"ignore\")\n-    except AttributeError:\n-        pass\n-    salted = salt + data\n-\n-    obj = hashlib.sha256(salted)\n-    return obj.hexdigest()\n-\n-\n-async def verify(string: str, hashed: str) -> bool:\n-    \"\"\"Verify if the given string matches the hashed value.\n-\n-    Args:\n-        string (str): The string to verify.\n-        hashed (str): The hashed value to compare against.\n-\n-    Returns:\n-        bool: True if the string matches the hashed value, False otherwise.\n-    \"\"\"\n-    return await hash(string) == hashed\n+\tdef __init__(A,name):'Initialize UIDNS with a name.';A.name=name\n+\t@property\n+\tdef bytes(self):'Return the bytes representation of the name.';return self.name.encode()\n+def uid(value=None,ns=DEFAULT_NS):\n+\t'Generate a UUID based on the provided value and namespace.\\n\\n    Args:\\n        value (str): The value to generate the UUID from. If None, a new UUID is created.\\n        ns (str): The namespace to use for UUID generation.\\n\\n    Returns:\\n        str: The generated UUID as a string.\\n    ';A=value\n+\ttry:ns=ns.decode()\n+\texcept AttributeError:pass\n+\tif not A:A=str(uuid.uuid4())\n+\ttry:A=A.decode()\n+\texcept AttributeError:pass\n+\treturn str(uuid.uuid5(UIDNS(ns),A))\n+async def hash(data,salt=DEFAULT_SALT):\n+\t'Hash the given data with the specified salt using SHA-256.\\n\\n    Args:\\n        data (str): The data to hash.\\n        salt (str): The salt to use for hashing.\\n\\n    Returns:\\n        str: The hexadecimal representation of the hashed data.\\n    ';C='ignore';A=salt;B=data\n+\ttry:B=B.encode(errors=C)\n+\texcept AttributeError:pass\n+\ttry:A=A.encode(errors=C)\n+\texcept AttributeError:pass\n+\tD=A+B;E=hashlib.sha256(D);return E.hexdigest()\n+async def verify(string,hashed):'Verify if the given string matches the hashed value.\\n\\n    Args:\\n        string (str): The string to verify.\\n        hashed (str): The hashed value to compare against.\\n\\n    Returns:\\n        bool: True if the string matches the hashed value, False otherwise.\\n    ';return await hash(string)==hashed\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex c6d2afc..eb735b1 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,67 +1,42 @@\n+_B='uid'\n+_A=None\n from snek.mapper import get_mapper\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n-\n-\n class BaseService:\n-\n-    mapper_name: BaseMapper = None\n-\n-    @property\n-    def services(self):\n-        return self.app.services\n-\n-    def __init__(self, app):\n-        self.app = app\n-        self.cache = app.cache\n-        if self.mapper_name:\n-            self.mapper = get_mapper(self.mapper_name, app=self.app)\n-        else:\n-            self.mapper = None\n-\n-    async def exists(self, uid=None, **kwargs):\n-        if uid:\n-            if not kwargs and await self.cache.get(uid):\n-                return True\n-            kwargs[\"uid\"] = uid\n-        return await self.count(**kwargs) > 0\n-\n-    async def count(self, **kwargs):\n-        return await self.mapper.count(**kwargs)\n-\n-    async def new(self, **kwargs):\n-        return await self.mapper.new()\n-\n-    async def query(self, sql, *args):\n-        for record in self.app.db.query(sql, *args):\n-            yield record\n-\n-    async def get(self, uid=None, **kwargs):\n-        if uid:\n-            if not kwargs:\n-                result = await self.cache.get(uid)\n-                if False and result and result.__class__ == self.mapper.model_class:\n-                    return result\n-            kwargs[\"uid\"] = uid\n-\n-        result = await self.mapper.get(**kwargs)\n-        if result:\n-            await self.cache.set(result[\"uid\"], result)\n-        return result\n-\n-    async def save(self, model: UserModel):\n-        if await self.mapper.save(model):\n-            await self.cache.set(model[\"uid\"], model)\n-            return True\n-        errors = await model.errors\n-        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n-\n-    async def find(self, **kwargs):\n-        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n-            kwargs[\"_limit\"] = 60\n-        async for model in self.mapper.find(**kwargs):\n-            yield model\n-\n-    async def delete(self, **kwargs):\n-        return await self.mapper.delete(**kwargs)\n+\tmapper_name:BaseMapper=_A\n+\t@property\n+\tdef services(self):return self.app.services\n+\tdef __init__(A,app):\n+\t\tA.app=app;A.cache=app.cache\n+\t\tif A.mapper_name:A.mapper=get_mapper(A.mapper_name,app=A.app)\n+\t\telse:A.mapper=_A\n+\tasync def exists(C,uid=_A,**A):\n+\t\tB=uid\n+\t\tif B:\n+\t\t\tif not A and await C.cache.get(B):return True\n+\t\t\tA[_B]=B\n+\t\treturn await C.count(**A)>0\n+\tasync def count(A,**B):return await A.mapper.count(**B)\n+\tasync def new(A,**B):return await A.mapper.new()\n+\tasync def query(A,sql,*B):\n+\t\tfor C in A.app.db.query(sql,*B):yield C\n+\tasync def get(B,uid=_A,**C):\n+\t\tD=uid\n+\t\tif D:\n+\t\t\tif not C:\n+\t\t\t\tA=await B.cache.get(D)\n+\t\t\t\tif False and A and A.__class__==B.mapper.model_class:return A\n+\t\t\tC[_B]=D\n+\t\tA=await B.mapper.get(**C)\n+\t\tif A:await B.cache.set(A[_B],A)\n+\t\treturn A\n+\tasync def save(B,model):\n+\t\tA=model\n+\t\tif await B.mapper.save(A):await B.cache.set(A[_B],A);return True\n+\t\tC=await A.errors;raise Exception(f\"Couldn't save model. Errors: f{C}\")\n+\tasync def find(C,**A):\n+\t\tB='_limit'\n+\t\tif B not in A or int(A.get(B))>30:A[B]=60\n+\t\tasync for D in C.mapper.find(**A):yield D\n+\tasync def delete(A,**B):return await A.mapper.delete(**B)\n\\ No newline at end of file\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d4b6819..1630219 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,249 +1,82 @@\n+_G=':snek1:'\n+_F='status'\n+_E='_to_html'\n+_D='alias'\n+_C=True\n+_B='html.parser'\n+_A='href'\n import re\n from types import SimpleNamespace\n-\n import emoji\n from bs4 import BeautifulSoup\n-from jinja2 import TemplateSyntaxError, nodes\n+from jinja2 import TemplateSyntaxError,nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-\n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n-    \"en\": \":snek1:\",\n-    \"status\": 2,\n-    \"E\": 0.6,\n-    \"alias\": [\":snek1:\"],\n-}\n-\n-emoji.EMOJI_DATA[\n-    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\"\"\"\n-] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n-\n-\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />']={'en':_G,_F:2,'E':.6,_D:[_G]}\n+emoji.EMOJI_DATA['\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n']={'en':':a1:',_F:2,'E':.6,_D:[':a1:']}\n def set_link_target_blank(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-\n-    for element in soup.find_all(\"a\"):\n-        element.attrs[\"target\"] = \"_blank\"\n-        element.attrs[\"rel\"] = \"noopener noreferrer\"\n-        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n-        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n-\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):element.attrs['target']='_blank';element.attrs['rel']='noopener noreferrer';element.attrs['referrerpolicy']='no-referrer';element.attrs[_A]=element.attrs[_A].strip('.').strip(',')\n+\treturn str(soup)\n def embed_youtube(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\t\tvideo_name=element.attrs[_A].split('/')[-1]\n+\t\t\tif'v='in element.attrs[_A]:video_name=element.attrs[_A].split('?v=')[1].split('&')[0]\n+\treturn str(soup)\n def embed_image(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".png\",\n-            \".jpg\",\n-            \".jpeg\",\n-            \".gif\",\n-            \".webp\",\n-            \".svg\",\n-            \".bmp\",\n-            \".tiff\",\n-            \".ico\",\n-            \".heif\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\tfor extension in['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.tiff','.ico','.heif']:\n+\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<img src=\"{element.attrs[_A]}\" title=\"{element.attrs[_A]}\" alt=\"{element.attrs[_A]}\" />';element.replace_with(BeautifulSoup(embed_template,_B))\n+\treturn str(soup)\n def embed_media(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".mp4\",\n-            \".mp3\",\n-            \".wav\",\n-            \".ogg\",\n-            \".webm\",\n-            \".flac\",\n-            \".aac\",\n-            \".mpg\",\n-            \".avi\",\n-            \".wmv\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\tfor extension in['.mp4','.mp3','.wav','.ogg','.webm','.flac','.aac','.mpg','.avi','.wmv']:\n+\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<video controls> <source src=\"{element.attrs[_A]}\">Your browser does not support the video tag.</video>';element.replace_with(BeautifulSoup(embed_template,_B))\n+\treturn str(soup)\n def linkify_https(text):\n-        return text\n-\n-    soup = BeautifulSoup(text, \"html.parser\")\n-\n-    for element in soup.find_all(text=True):\n-        parent = element.parent\n-        if parent.name in [\"a\", \"script\", \"style\"]:\n-            continue\n-\n-        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n-        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n-\n-    return set_link_target_blank(str(soup))\n-\n-\n+\tfor element in soup.find_all(text=_C):\n+\t\tparent=element.parent\n+\t\tif parent.name in['a','script','style']:continue\n+\t\tnew_text=re.sub(url_pattern,'<a href=\"\\\\g<0>\">\\\\g<0></a>',element);element.replace_with(BeautifulSoup(new_text,_B))\n+\treturn set_link_target_blank(str(soup))\n class EmojiExtension(Extension):\n-    tags = {\"emoji\"}\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        return emoji.emojize(caller(), language=\"alias\")\n-\n-\n+\ttags={'emoji'}\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endemoji'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):return emoji.emojize(caller(),language=_D)\n class LinkifyExtension(Extension):\n-    tags = {\"linkify\"}\n-\n-    def __init__(self, environment):\n-        self.app = SimpleNamespace(jinja2_env=environment)\n-        super(LinkifyExtension, self).__init__(environment)\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        result = linkify_https(caller())\n-        result = embed_media(result)\n-        result = embed_image(result)\n-        result = embed_youtube(result)\n-        return result\n-\n-\n+\ttags={'linkify'}\n+\tdef __init__(self,environment):self.app=SimpleNamespace(jinja2_env=environment);super(LinkifyExtension,self).__init__(environment)\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endlinkify'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):result=linkify_https(caller());result=embed_media(result);result=embed_image(result);result=embed_youtube(result);return result\n class PythonExtension(Extension):\n-    tags = {\"py3\"}\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-\n-        def fn(source):\n-            import subprocess\n-\n-            def system(command):\n-                if isinstance(command):\n-                    command = command.split(\" \")\n-                from io import StringIO\n-\n-                stdout = StringIO()\n-                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n-                return stdout.getvalue()\n-\n-            to_write = []\n-\n-            def render(text):\n-                global to_write\n-                to_write.append(text)\n-\n-            exec(source)\n-            return \"\".join(to_write)\n-\n-        return str(fn(caller()))\n+\ttags={'py3'}\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endpy3'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):\n+\t\tdef fn(source):\n+\t\t\timport subprocess\n+\t\t\tdef system(command):\n+\t\t\t\tif isinstance(command):command=command.split(' ')\n+\t\t\t\tfrom io import StringIO;stdout=StringIO();subprocess.run(command,stderr=stdout,stdout=stdout,text=_C);return stdout.getvalue()\n+\t\t\tto_write=[]\n+\t\t\tdef render(text):global to_write;to_write.append(text)\n+\t\t\texec(source);return''.join(to_write)\n+\t\treturn str(fn(caller()))\n\\ No newline at end of file\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex c5410b6..82207c7 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,113 +1,49 @@\n-import asyncio\n-import os\n-\n-try:\n-    import pty\n-except Exception as ex:\n-    print(\"You are not able to run a terminal. See error:\")\n-    print(ex)\n+_A=None\n+import asyncio,os\n+try:import pty\n+except Exception as ex:print('You are not able to run a terminal. See error:');print(ex)\n import subprocess\n-\n-commands = {\n-    \"alpine\": \"docker run -it alpine /bin/sh\",\n-    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n-}\n-\n-\n+commands={'alpine':'docker run -it alpine /bin/sh','r':'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh'}\n class TerminalSession:\n-    def __init__(self, command):\n-        self.master, self.slave = None, None\n-        self.process = None\n-        self.sockets = []\n-        self.history = b\"\"\n-        self.history_size = 1024 * 20\n-        self.command = command\n-        self.start_process(self.command)\n-\n-    def start_process(self, command):\n-        if not self.is_running():\n-            if self.master:\n-                os.close(self.master)\n-                os.close(self.slave)\n-                self.master = None\n-                self.slave = None\n-\n-            self.master, self.slave = pty.openpty()\n-            self.process = subprocess.Popen(\n-                command.split(\" \"),\n-                stdin=self.slave,\n-                stdout=self.slave,\n-                stderr=self.slave,\n-                bufsize=0,\n-                universal_newlines=True,\n-            )\n-\n-    def is_running(self):\n-        if not self.process:\n-            return False\n-        asyncio.get_event_loop()\n-        return self.process.poll() is None\n-\n-    async def add_websocket(self, ws):\n-        self.start_process(self.command)\n-        asyncio.create_task(self.read_output(ws))\n-\n-    async def read_output(self, ws):\n-        self.sockets.append(ws)\n-        if len(self.sockets) > 1 and self.history:\n-            start = 0\n-            try:\n-                start = self.history.index(b\"\\n\")\n-            except ValueError:\n-                pass\n-            await ws.send_bytes(self.history[start:])\n-            return\n-        loop = asyncio.get_event_loop()\n-        while True:\n-            try:\n-                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n-                if not data:\n-                    break\n-                self.history += data\n-                if len(self.history) > self.history_size:\n-                    self.history = self.history[: 0 - self.history_size]\n-                try:\n-                    for ws in self.sockets:\n-                except:\n-                    self.sockets.remove(ws)\n-            except Exception:\n-                await self.close()\n-                break\n-\n-    async def close(self):\n-        print(\"Terminating process\")\n-        if self.process:\n-            self.process.terminate()\n-            self.process = None\n-        if self.master:\n-            os.close(self.master)\n-            os.close(self.slave)\n-            self.master = None\n-            self.slave = None\n-\n-        print(\"Terminated process\")\n-        for ws in self.sockets:\n-            try:\n-                await ws.close()\n-            except Exception:\n-                pass\n-        self.sockets = []\n-\n-    async def write_input(self, data):\n-        try:\n-            data = data.encode()\n-        except AttributeError:\n-            pass\n-        try:\n-            await asyncio.get_event_loop().run_in_executor(\n-                None, os.write, self.master, data\n-            )\n-        except Exception as ex:\n-            print(ex)\n-            await self.close()\n+\tdef __init__(A,command):A.master,A.slave=_A,_A;A.process=_A;A.sockets=[];A.history=b'';A.history_size=20480;A.command=command;A.start_process(A.command)\n+\tdef start_process(A,command):\n+\t\tif not A.is_running():\n+\t\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n+\t\t\tA.master,A.slave=pty.openpty();A.process=subprocess.Popen(command.split(' '),stdin=A.slave,stdout=A.slave,stderr=A.slave,bufsize=0,universal_newlines=True)\n+\tdef is_running(A):\n+\t\tif not A.process:return False\n+\t\tasyncio.get_event_loop();return A.process.poll()is _A\n+\tasync def add_websocket(A,ws):A.start_process(A.command);asyncio.create_task(A.read_output(ws))\n+\tasync def read_output(A,ws):\n+\t\tB=ws;A.sockets.append(B)\n+\t\tif len(A.sockets)>1 and A.history:\n+\t\t\tD=0\n+\t\t\ttry:D=A.history.index(b'\\n')\n+\t\t\texcept ValueError:pass\n+\t\t\tawait B.send_bytes(A.history[D:]);return\n+\t\tE=asyncio.get_event_loop()\n+\t\twhile True:\n+\t\t\ttry:\n+\t\t\t\tC=await E.run_in_executor(_A,os.read,A.master,1024)\n+\t\t\t\tif not C:break\n+\t\t\t\tA.history+=C\n+\t\t\t\tif len(A.history)>A.history_size:A.history=A.history[:0-A.history_size]\n+\t\t\t\ttry:\n+\t\t\t\t\tfor B in A.sockets:await B.send_bytes(C)\n+\t\t\t\texcept:A.sockets.remove(B)\n+\t\t\texcept Exception:await A.close();break\n+\tasync def close(A):\n+\t\tprint('Terminating process')\n+\t\tif A.process:A.process.terminate();A.process=_A\n+\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n+\t\tprint('Terminated process')\n+\t\tfor B in A.sockets:\n+\t\t\ttry:await B.close()\n+\t\t\texcept Exception:pass\n+\t\tA.sockets=[]\n+\tasync def write_input(B,data):\n+\t\tA=data\n+\t\ttry:A=A.encode()\n+\t\texcept AttributeError:pass\n+\t\ttry:await asyncio.get_event_loop().run_in_executor(_A,os.write,B.master,A)\n+\t\texcept Exception as C:print(C);await B.close()\n\\ No newline at end of file\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 70379ef..be19178 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,75 +1,31 @@\n from aiohttp import web\n-\n from snek.system.markdown import render_markdown\n-\n-\n class BaseView(web.View):\n-\n-    login_required = False\n-\n-    async def _iter(self):\n-        if self.login_required and (\n-            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n-        ):\n-            return web.HTTPFound(\"/\")\n-        return await super()._iter()\n-\n-    @property\n-    def base_url(self):\n-        return str(self.request.url.with_path(\"\").with_query(\"\"))\n-\n-    @property\n-    def app(self):\n-        return self.request.app\n-\n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    @property\n-    def services(self):\n-        return self.app.services\n-\n-    async def json_response(self, data, **kwargs):\n-        return web.json_response(data, **kwargs)\n-\n-    @property\n-    def session(self):\n-        return self.request.session\n-\n-    async def render_template(self, template_name, context=None):\n-        if template_name.endswith(\".md\"):\n-            response = await self.request.app.render_template(\n-                template_name, self.request, context\n-            )\n-            body = await render_markdown(self.app, response.body.decode())\n-            return web.Response(body=body, content_type=\"text/html\")\n-        return await self.request.app.render_template(\n-            template_name, self.request, context\n-        )\n-\n-\n+\tlogin_required=False\n+\tasync def _iter(A):\n+\t\tif A.login_required and(not A.session.get('logged_in')or not A.session.get('uid')):return web.HTTPFound('/')\n+\t\treturn await super()._iter()\n+\t@property\n+\tdef base_url(self):return str(self.request.url.with_path('').with_query(''))\n+\t@property\n+\tdef app(self):return self.request.app\n+\t@property\n+\tdef db(self):return self.app.db\n+\t@property\n+\tdef services(self):return self.app.services\n+\tasync def json_response(B,data,**A):return web.json_response(data,**A)\n+\t@property\n+\tdef session(self):return self.request.session\n+\tasync def render_template(A,template_name,context=None):\n+\t\tC=context;B=template_name\n+\t\tif B.endswith('.md'):D=await A.request.app.render_template(B,A.request,C);E=await render_markdown(A.app,D.body.decode());return web.Response(body=E,content_type='text/html')\n+\t\treturn await A.request.app.render_template(B,A.request,C)\n class BaseFormView(BaseView):\n-\n-    form = None\n-\n-    async def get(self):\n-        form = self.form(app=self.app)\n-\n-        return await self.json_response(await form.to_json())\n-\n-    async def post(self):\n-        form = self.form(app=self.app)\n-        post = await self.request.json()\n-        form.set_user_data(post[\"form\"])\n-        result = await form.to_json()\n-        if post.get(\"action\") == \"validate\":\n-            pass\n-        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n-            result = await self.submit(form)\n-            return await self.json_response(result)\n-        return await self.json_response(result)\n-\n-    async def submit(self, model=None):\n-        pass\n+\tform=None\n+\tasync def get(A):B=A.form(app=A.app);return await A.json_response(await B.to_json())\n+\tasync def post(A):\n+\t\tE='action';C=A.form(app=A.app);D=await A.request.json();C.set_user_data(D['form']);B=await C.to_json()\n+\t\tif D.get(E)=='validate':0\n+\t\tif D.get(E)=='submit'and B['is_valid']:B=await A.submit(C);return await A.json_response(B)\n+\t\treturn await A.json_response(B)\n+\tasync def submit(A,model=None):0\n\\ No newline at end of file\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex aba57ae..740ec7a 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,39 +1,5 @@\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class AboutHTMLView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"about.html\")\n-\n-\n+\tasync def get(A):return await A.render_template('about.html')\n class AboutMDView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"about.md\")\n+\tasync def get(A):return await A.render_template('about.md')\n\\ No newline at end of file\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex a85b876..c95384a 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -1,44 +1,10 @@\n-\n-\n-\n import uuid\n-\n from aiohttp import web\n from multiavatar import multiavatar\n-\n from snek.system.view import BaseView\n-\n-\n class AvatarView(BaseView):\n-    login_required = False\n-\n-    async def get(self):\n-        uid = self.request.match_info.get(\"uid\")\n-        if uid == \"unique\":\n-            uid = str(uuid.uuid4())\n-        avatar = multiavatar.multiavatar(uid, True, None)\n-        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n-        return response\n+\tlogin_required=False\n+\tasync def get(C):\n+\t\tA=C.request.match_info.get('uid')\n+\t\tif A=='unique':A=str(uuid.uuid4())\n+\t\tD=multiavatar.multiavatar(A,True,None);B=web.Response(text=D,content_type='image/svg+xml');B.headers['Cache-Control']=f\"public, max-age={56154}\";return B\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex bb63413..ce1e31c 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,37 +1,5 @@\n-\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class DocsHTMLView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"docs.html\")\n-\n-\n+\tasync def get(A):return await A.render_template('docs.html')\n class DocsMDView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"docs.md\")\n+\tasync def get(A):return await A.render_template('docs.md')\n\\ No newline at end of file\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex e3c3343..630cc3a 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,269 +1,99 @@\n+_P='Path not found'\n+_O='application/octet-stream'\n+_N='items'\n+_M='size'\n+_L='mimetype'\n+_K='name'\n+_J='rel_path'\n+_I='dir'\n+_H='url'\n+_G=None\n+_F='path'\n+_E='status'\n+_D='file'\n+_C='uid'\n+_B='absolute_url'\n+_A='type'\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n-import os\n-import mimetypes\n+import os,mimetypes\n from aiohttp import web\n-from urllib.parse import unquote, quote\n+from urllib.parse import unquote,quote\n from datetime import datetime\n-\n-\n-\n-\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n-\"\"\"\n from aiohttp import web\n from pathlib import Path\n-import mimetypes, urllib.parse\n-\n-BASE_DIR   = Path(__file__).parent.resolve()\n+import mimetypes,urllib.parse\n+BASE_DIR=Path(__file__).parent.resolve()\n+ROOT_DIR=(BASE_DIR/'storage').resolve()\n+ASSETS_DIR=(BASE_DIR/'assets').resolve()\n ROOT_DIR.mkdir(exist_ok=True)\n ASSETS_DIR.mkdir(exist_ok=True)\n-\n-\n-def safe_resolve_path(rel: str) -> Path:\n-    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n-    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n-    if target == ROOT_DIR or ROOT_DIR in target.parents:\n-        return target\n-    raise FileNotFoundError(\"Unsafe path\")\n-\n-\n+def safe_resolve_path(rel):\n+\t'Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.';A=(ROOT_DIR/rel.lstrip('/')).resolve()\n+\tif A==ROOT_DIR or ROOT_DIR in A.parents:return A\n+\traise FileNotFoundError('Unsafe path')\n class DriveView(BaseView):\n-    async def get(self):\n-        rel = self.request.query.get(\"path\", \"\")\n-        offset = int(self.request.query.get(\"offset\", 0))\n-        limit  = int(self.request.query.get(\"limit\", 20))\n-        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n-        if rel:\n-            target.joinpath(rel)\n-\n-        if not target.exists():\n-            return web.json_response({\"error\": \"Not found\"}, status=404)\n-\n-        if target.is_dir():\n-            entries = []\n-            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n-                item_path = (Path(rel) / p.name).as_posix()\n-                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n-                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n-                        if p.is_file() else None)\n-                entries.append({\n-                    \"name\": p.name,\n-                    \"type\": \"directory\" if p.is_dir() else \"file\",\n-                    \"mimetype\": mime,\n-                    \"size\": p.stat().st_size if p.is_file() else None,\n-                    \"path\": item_path,\n-                    \"url\": url,\n-                })\n-            import json \n-            total = len(entries)\n-            items = entries[offset:offset+limit]\n-            return web.json_response({\n-                \"items\": json.loads(json.dumps(items,default=str)),\n-                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n-            })\n-        \n-        with open(target, \"rb\") as f:\n-            content = f.read()\n-            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n-        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n-        return web.json_response({\n-            \"name\": target.name,\n-            \"type\": \"file\",\n-            \"mimetype\": mimetypes.guess_type(target.name)[0],\n-            \"size\": target.stat().st_size,\n-            \"path\": rel,\n-            \"url\": str(url),\n-        })\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n+\tasync def get(C):\n+\t\tH='limit';I='offset';D=C.request.query.get(_F,'');E=int(C.request.query.get(I,0));J=int(C.request.query.get(H,20));A=await C.services.user.get_home_folder(C.session.get(_C))\n+\t\tif D:A.joinpath(D)\n+\t\tif not A.exists():return web.json_response({'error':'Not found'},status=404)\n+\t\tif A.is_dir():\n+\t\t\tF=[]\n+\t\t\tfor B in sorted(A.iterdir(),key=lambda p:(p.is_file(),p.name.lower())):K=(Path(D)/B.name).as_posix();M=mimetypes.guess_type(B.name)[0]if B.is_file()else'inode/directory';G=C.request.url.with_path(f\"/drive/{urllib.parse.quote(K)}\")if B.is_file()else _G;F.append({_K:B.name,_A:'directory'if B.is_dir()else _D,_L:M,_M:B.stat().st_size if B.is_file()else _G,_F:K,_H:G})\n+\t\t\timport json as L;N=len(F);O=F[E:E+J];return web.json_response({_N:L.loads(L.dumps(O,default=str)),'pagination':{I:E,H:J,'total':N}})\n+\t\twith open(A,'rb')as P:Q=P.read();return web.Response(body=Q,content_type=mimetypes.guess_type(A.name)[0])\n+\t\tG=C.request.url.with_path(f\"/drive/{urllib.parse.quote(D)}\");return web.json_response({_K:A.name,_A:_D,_L:mimetypes.guess_type(A.name)[0],_M:A.stat().st_size,_F:D,_H:str(G)})\n class DriveView222(BaseView):\n-    PAGE_SIZE = 20\n-\n-    async def base_path(self):\n-        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n-\n-    async def get_full_path(self, rel_path):\n-        base_path = await self.base_path()\n-        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n-        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n-        if not full_path.startswith(os.path.abspath(base_path)):\n-            raise web.HTTPForbidden(reason=\"Invalid path\")\n-        return full_path\n-\n-    async def make_absolute_url(self, rel_path):\n-        rel_path = rel_path.lstrip(\"/\")\n-        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n-        return url\n-\n-    async def entry_details(self, dir_path, entry, parent_rel_path):\n-        entry_path = os.path.join(dir_path, entry)\n-        stat = os.stat(entry_path)\n-        is_dir = os.path.isdir(entry_path)\n-        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n-        size = stat.st_size if not is_dir else None\n-        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n-        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n-        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n-        return {\n-            \"name\": entry,\n-            \"type\": \"dir\" if is_dir else \"file\",\n-            \"mimetype\": mimetype,\n-            \"size\": size,\n-            \"created_at\": created_at,\n-            \"updated_at\": updated_at,\n-            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n-        }\n-\n-    async def get(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        page = int(self.request.query.get(\"page\", 1))\n-        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n-        abs_url = await self.make_absolute_url(rel_path)\n-\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"Path not found\")\n-\n-        if os.path.isdir(full_path):\n-            entries = os.listdir(full_path)\n-            entries.sort()\n-            start = (page - 1) * page_size\n-            end = start + page_size\n-            paged_entries = entries[start:end]\n-            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n-            return web.json_response({\n-                \"path\": rel_path,\n-                \"absolute_url\": abs_url,\n-                \"entries\": details,\n-                \"total\": len(entries),\n-                \"page\": page,\n-                \"page_size\": page_size,\n-            })\n-        else:\n-            with open(full_path, \"rb\") as f:\n-                content = f.read()\n-            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n-            headers = {\"X-Absolute-Url\": abs_url}\n-            return web.Response(body=content, content_type=mimetype, headers=headers)\n-\n-    async def post(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if os.path.exists(full_path):\n-            raise web.HTTPConflict(reason=\"File or directory already exists\")\n-        data = await self.request.post()\n-        if data.get(\"type\") == \"dir\":\n-            os.makedirs(full_path)\n-            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n-        else:\n-            file_field = data.get(\"file\")\n-            if not file_field:\n-                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n-            with open(full_path, \"wb\") as f:\n-                f.write(file_field.file.read())\n-            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n-\n-    async def put(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"File not found\")\n-        if os.path.isdir(full_path):\n-            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n-        body = await self.request.read()\n-        with open(full_path, \"wb\") as f:\n-            f.write(body)\n-        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n-\n-    async def delete(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"Path not found\")\n-        if os.path.isdir(full_path):\n-            os.rmdir(full_path)\n-            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n-        else:\n-            os.remove(full_path)\n-            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n-\n-\n+\tPAGE_SIZE=20\n+\tasync def base_path(A):return await A.services.user.get_home_folder(A.session.get(_C))\n+\tasync def get_full_path(C,rel_path):\n+\t\tA=await C.base_path();D=os.path.normpath(unquote(rel_path or''));B=os.path.abspath(os.path.join(A,D))\n+\t\tif not B.startswith(os.path.abspath(A)):raise web.HTTPForbidden(reason='Invalid path')\n+\t\treturn B\n+\tasync def make_absolute_url(B,rel_path):A=rel_path;A=A.lstrip('/');C=str(B.request.url.with_path(f\"/drive/{quote(A)}\"));return C\n+\tasync def entry_details(E,dir_path,entry,parent_rel_path):A=entry;B=os.path.join(dir_path,A);C=os.stat(B);D=os.path.isdir(B);F=_G if D else mimetypes.guess_type(B)[0]or _O;G=C.st_size if not D else _G;H=datetime.fromtimestamp(C.st_ctime).isoformat();I=datetime.fromtimestamp(C.st_mtime).isoformat();J=os.path.join(parent_rel_path,A).replace('\\\\','/');return{_K:A,_A:_I if D else _D,_L:F,_M:G,'created_at':H,'updated_at':I,_B:await E.make_absolute_url(J)}\n+\tasync def get(A):\n+\t\tF='page_size';G='page';C=A.request.match_info.get(_J,'');B=await A.get_full_path(C);H=int(A.request.query.get(G,1));D=int(A.request.query.get(F,A.PAGE_SIZE));I=await A.make_absolute_url(C)\n+\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason=_P)\n+\t\tif os.path.isdir(B):E=os.listdir(B);E.sort();J=(H-1)*D;K=J+D;L=E[J:K];M=[await A.entry_details(B,D,C)for D in L];return web.json_response({_F:C,_B:I,'entries':M,'total':len(E),G:H,F:D})\n+\t\telse:\n+\t\t\twith open(B,'rb')as N:O=N.read()\n+\t\t\tP=mimetypes.guess_type(B)[0]or _O;Q={'X-Absolute-Url':I};return web.Response(body=O,content_type=P,headers=Q)\n+\tasync def post(A):\n+\t\tC='created';D=A.request.match_info.get(_J,'');B=await A.get_full_path(D);E=await A.make_absolute_url(D)\n+\t\tif os.path.exists(B):raise web.HTTPConflict(reason='File or directory already exists')\n+\t\tF=await A.request.post()\n+\t\tif F.get(_A)==_I:os.makedirs(B);return web.json_response({_E:C,_A:_I,_B:E})\n+\t\telse:\n+\t\t\tG=F.get(_D)\n+\t\t\tif not G:raise web.HTTPBadRequest(reason='No file uploaded')\n+\t\t\twith open(B,'wb')as H:H.write(G.file.read())\n+\t\t\treturn web.json_response({_E:C,_A:_D,_B:E})\n+\tasync def put(A):\n+\t\tC=A.request.match_info.get(_J,'');B=await A.get_full_path(C);D=await A.make_absolute_url(C)\n+\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason='File not found')\n+\t\tif os.path.isdir(B):raise web.HTTPBadRequest(reason='Cannot overwrite directory')\n+\t\tE=await A.request.read()\n+\t\twith open(B,'wb')as F:F.write(E)\n+\t\treturn web.json_response({_E:'updated',_B:D})\n+\tasync def delete(B):\n+\t\tC='deleted';D=B.request.match_info.get(_J,'');A=await B.get_full_path(D);E=await B.make_absolute_url(D)\n+\t\tif not os.path.exists(A):raise web.HTTPNotFound(reason=_P)\n+\t\tif os.path.isdir(A):os.rmdir(A);return web.json_response({_E:C,_A:_I,_B:E})\n+\t\telse:os.remove(A);return web.json_response({_E:C,_A:_D,_B:E})\n class DriveViewi2(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-\n-        drive_uid = self.request.match_info.get(\"drive\")\n-            \n-\n-        before = self.request.query.get(\"before\")\n-        filters = {} \n-        if before:\n-            filters[\"created_at__lt\"] = before\n-\n-        if drive_uid:\n-            filters['drive_uid'] = drive_uid \n-            drive = await self.services.drive.get(uid=drive_uid)\n-            drive_items = []\n-            \n-            \n-            \n-            async for item in self.services.drive_item.find(**filters):\n-                record = item.record\n-                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n-                drive_items.append(record)\n-            return web.json_response(drive_items)\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        drives = []\n-        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n-            record = drive.record\n-            record[\"items\"] = []\n-            async for item in drive.items:\n-                drive_item_record = item.record\n-                drive_item_record[\"url\"] = (\n-                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n-                )\n-                record[\"items\"].append(item.record)\n-            drives.append(record)\n-\n-        return web.json_response(drives)\n+\tlogin_required=True\n+\tasync def get(A):\n+\t\tG='/drive.bin/';D=A.request.match_info.get('drive');H=A.request.query.get('before');E={}\n+\t\tif H:E['created_at__lt']=H\n+\t\tif D:\n+\t\t\tE['drive_uid']=D;F=await A.services.drive.get(uid=D);I=[]\n+\t\t\tasync for C in A.services.drive_item.find(**E):B=C.record;B[_H]=G+B[_C]+'.'+C.extension;I.append(B)\n+\t\t\treturn web.json_response(I)\n+\t\tL=await A.services.user.get(uid=A.session.get(_C));J=[]\n+\t\tasync for F in A.services.drive.get_by_user(L[_C]):\n+\t\t\tB=F.record;B[_N]=[]\n+\t\t\tasync for C in F.items:K=C.record;K[_H]=G+K[_C]+'.'+C.extension;B[_N].append(C.record)\n+\t\t\tJ.append(B)\n+\t\treturn web.json_response(J)\n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2f44443..2bd3245 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,23 +1,6 @@\n-\n-\n-\n-\n-\n-\n-\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class IndexView(BaseView):\n-    async def get(self):\n-        if self.session.get(\"uid\"):\n-            return web.HTTPFound(\"/web.html\")\n-\n-        return await self.render_template(\"index.html\")\n+\tasync def get(A):\n+\t\tif A.session.get('uid'):return web.HTTPFound('/web.html')\n+\t\treturn await A.render_template('index.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex fe8cf4d..849a8e1 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,44 +1,15 @@\n-\n-\n-\n-\n+_B='/web.html'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n-\n-\n class LoginView(BaseFormView):\n-    form = LoginForm\n-\n-    login_required = False\n-\n-    async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        return await self.render_template(\n-            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n-        )\n-\n-    async def submit(self, form):\n-        if await form.is_valid:\n-            user = await self.services.user.get(\n-                username=form[\"username\"], deleted_at=None\n-            )\n-            await self.services.user.save(user)\n-            self.session.update(\n-                {\n-                    \"logged_in\": True,\n-                    \"username\": user[\"username\"],\n-                    \"uid\": user[\"uid\"],\n-                    \"color\": user[\"color\"],\n-                }\n-            )\n-            return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+\tform=LoginForm;login_required=False\n+\tasync def get(A):\n+\t\tif A.session.get(_A):return web.HTTPFound(_B)\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\treturn await A.render_template('login.html',{'form':await A.form(app=A.app).to_json()})\n+\tasync def submit(B,form):\n+\t\tD='color';E='uid';C='username'\n+\t\tif await form.is_valid:A=await B.services.user.get(username=form[C],deleted_at=None);await B.services.user.save(A);B.session.update({_A:True,C:A[C],E:A[E],D:A[D]});return{'redirect_url':_B}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex acf7c75..39b5be3 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,23 +1,8 @@\n-\n-\n-\n-\n-\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n-\n-\n class LoginFormView(BaseFormView):\n-    form = LoginForm\n-\n-    async def submit(self, form):\n-        if await form.is_valid():\n-            self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = form.username.value\n-            self.session[\"uid\"] = form.uid.value\n-            return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+\tform=LoginForm\n+\tasync def submit(A,form):\n+\t\tB=form\n+\t\tif await B.is_valid():A.session['logged_in']=True;A.session['username']=B.username.value;A.session['uid']=B.uid.value;return{'redirect_url':'/web.html'}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 42016d8..5594774 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,56 +1,14 @@\n-\n-\n-\n-\n-\n-\n-\n-\n+_B='username'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class LogoutView(BaseView):\n-    redirect_url = \"/\"\n-    login_required = True\n-\n-    async def get(self):\n-        try:\n-            del self.session[\"logged_in\"]\n-            del self.session[\"uid\"]\n-            del self.session[\"username\"]\n-        except KeyError:\n-            pass\n-        return web.HTTPFound(self.redirect_url)\n-\n-    async def post(self):\n-        try:\n-            del self.session[\"logged_in\"]\n-            del self.session[\"uid\"]\n-            del self.session[\"username\"]\n-        except KeyError:\n-            pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n+\tredirect_url='/';login_required=True\n+\tasync def get(A):\n+\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n+\t\texcept KeyError:pass\n+\t\treturn web.HTTPFound(A.redirect_url)\n+\tasync def post(A):\n+\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n+\t\texcept KeyError:pass\n+\t\treturn await A.json_response({'redirect_url':A.redirect_url})\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 96eed8a..ba48820 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,41 +1,12 @@\n-\n-\n-\n-\n+_B='/web.html'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n-\n-\n class RegisterView(BaseFormView):\n-    form = RegisterForm\n-\n-    login_required = False\n-\n-    async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        return await self.render_template(\n-            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n-        )\n-\n-    async def submit(self, form):\n-        result = await self.app.services.user.register(\n-            form.email.value, form.username.value, form.password.value\n-        )\n-        self.request.session.update(\n-            {\n-                \"uid\": result[\"uid\"],\n-                \"username\": result[\"username\"],\n-                \"logged_in\": True,\n-                \"color\": result[\"color\"],\n-            }\n-        )\n-        return {\"redirect_url\": \"/web.html\"}\n+\tform=RegisterForm;login_required=False\n+\tasync def get(A):\n+\t\tif A.session.get(_A):return web.HTTPFound(_B)\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\treturn await A.render_template('register.html',{'form':await A.form(app=A.app).to_json()})\n+\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],_A:True,D:B[D]});return{'redirect_url':_B}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 7b98647..cf5dbbb 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,47 +1,5 @@\n-\n-\n-\n-\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n-\n-\n class RegisterFormView(BaseFormView):\n-    form = RegisterForm\n-\n-    async def submit(self, form):\n-        result = await self.app.services.user.register(\n-            form.email.value, form.username.value, form.password.value\n-        )\n-        self.request.session.update(\n-            {\n-                \"uid\": result[\"uid\"],\n-                \"username\": result[\"username\"],\n-                \"logged_in\": True,\n-                \"color\": result[\"color\"],\n-            }\n-        )\n-        return {\"redirect_url\": \"/web.html\"}\n+\tform=RegisterForm\n+\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],'logged_in':True,D:B[D]});return{'redirect_url':'/web.html'}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3161f49..1896ba8 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,283 +1,105 @@\n-\n-\n-\n-\n-\n-import json\n-import traceback\n-\n+_M='noresponse'\n+_L='deleted_at'\n+_K='Not allowed'\n+_J='password'\n+_I='logged_in'\n+_H='channel_uid'\n+_G='last_ping'\n+_F='nick'\n+_E=None\n+_D=True\n+_C=False\n+_B='username'\n+_A='uid'\n+import json,traceback\n from aiohttp import web\n-\n from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n-\n-\n class RPCView(BaseView):\n-\n-    class RPCApi:\n-        def __init__(self, view, ws):\n-            self.view = view\n-            self.app = self.view.app\n-            self.services = self.app.services\n-            self.ws = ws\n-\n-        @property\n-        def user_uid(self):\n-            return self.view.session.get(\"uid\")\n-\n-        @property\n-        def request(self):\n-            return self.view.request\n-\n-        def _require_login(self):\n-            if not self.is_logged_in:\n-                raise Exception(\"Not logged in\")\n-\n-        @property\n-        def is_logged_in(self):\n-            return self.view.session.get(\"logged_in\", False)\n-\n-        async def mark_as_read(self, channel_uid):\n-            self._require_login()\n-            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n-            return True\n-\n-        async def login(self, username, password):\n-            success = await self.services.user.validate_login(username, password)\n-            if not success:\n-                raise Exception(\"Invalid username or password\")\n-            user = await self.services.user.get(username=username)\n-            self.view.session[\"uid\"] = user[\"uid\"]\n-            self.view.session[\"logged_in\"] = True\n-            self.view.session[\"username\"] = user[\"username\"]\n-            self.view.session[\"user_nick\"] = user[\"nick\"]\n-            record = user.record\n-            del record[\"password\"]\n-            del record[\"deleted_at\"]\n-            await self.services.socket.add(\n-                self.ws, self.view.request.session.get(\"uid\")\n-            )\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.view.request.session.get(\"uid\"),\n-                deleted_at=None,\n-                is_banned=False,\n-            ):\n-                await self.services.socket.subscribe(\n-                    self.ws,\n-                    subscription[\"channel_uid\"],\n-                    self.view.request.session.get(\"uid\"),\n-                )\n-            return record\n-\n-        async def search_user(self, query):\n-            self._require_login()\n-            return [user[\"username\"] for user in await self.services.user.search(query)]\n-\n-        async def get_user(self, user_uid):\n-            self._require_login()\n-            if not user_uid:\n-                user_uid = self.user_uid\n-            user = await self.services.user.get(uid=user_uid)\n-            record = user.record\n-            del record[\"password\"]\n-            del record[\"deleted_at\"]\n-            if user_uid != user[\"uid\"]:\n-                del record[\"email\"]\n-            return record\n-\n-        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n-            self._require_login()\n-            messages = []\n-            for message in await self.services.channel_message.offset(\n-                channel_uid, offset or 0, timestamp or None\n-            ):\n-                extended_dict = await self.services.channel_message.to_extended_dict(\n-                    message\n-                )\n-                messages.append(extended_dict)\n-            return messages\n-\n-        async def get_channels(self):\n-            self._require_login()\n-            channels = []\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.user_uid, is_banned=False\n-            ):\n-                channel = await self.services.channel.get(\n-                    uid=subscription[\"channel_uid\"]\n-                )\n-                last_message = await channel.get_last_message()\n-                color = None\n-                if last_message:\n-                    last_message_user = await last_message.get_user()\n-                    color = last_message_user[\"color\"]\n-                channels.append(\n-                    {\n-                        \"name\": subscription[\"label\"],\n-                        \"uid\": subscription[\"channel_uid\"],\n-                        \"tag\": channel[\"tag\"],\n-                        \"new_count\": subscription[\"new_count\"],\n-                        \"is_moderator\": subscription[\"is_moderator\"],\n-                        \"is_read_only\": subscription[\"is_read_only\"],\n-                        \"new_count\": subscription[\"new_count\"],\n-                        \"color\": color,\n-                    }\n-                )\n-            return channels\n-\n-        async def send_message(self, channel_uid, message):\n-            self._require_login()\n-            await self.services.chat.send(self.user_uid, channel_uid, message)\n-            return True\n-\n-        async def echo(self, *args):\n-            self._require_login()\n-            return args\n-\n-        async def query(self, *args):\n-            self._require_login()\n-            query = args[0]\n-            lowercase = query.lower()\n-            if (\n-                any(\n-                    keyword in lowercase\n-                    for keyword in [\n-                        \"drop\",\n-                        \"alter\",\n-                        \"update\",\n-                        \"delete\",\n-                        \"replace\",\n-                        \"insert\",\n-                        \"truncate\",\n-                    ]\n-                )\n-                and \"select\" not in lowercase\n-            ):\n-                raise Exception(\"Not allowed\")\n-            records = [\n-                dict(record) async for record in self.services.channel.query(args[0])\n-            ]\n-            for record in records:\n-                try:\n-                    del record[\"email\"]\n-                except KeyError:\n-                    pass\n-                try:\n-                    del record[\"password\"]\n-                except KeyError:\n-                    pass\n-                try:\n-                    del record[\"message\"]\n-                except:\n-                    pass\n-                try:\n-                    del record[\"html\"]\n-                except:\n-                    pass\n-            return [\n-                dict(record) async for record in self.services.channel.query(args[0])\n-            ]\n-\n-        async def __call__(self, data):\n-            try:\n-                call_id = data.get(\"callId\")\n-                method_name = data.get(\"method\")\n-                if method_name.startswith(\"_\"):\n-                    raise Exception(\"Not allowed\")\n-                args = data.get(\"args\") or []\n-                if hasattr(super(), method_name) or not hasattr(self, method_name):\n-                    return await self._send_json(\n-                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n-                    )\n-                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n-                if not method:\n-                    raise Exception(\"Method not found\")\n-                success = True\n-                try:\n-                    result = await method(*args)\n-                except Exception as ex:\n-                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n-                    success = False\n-                if result != \"noresponse\":\n-                    await self._send_json(\n-                        {\"callId\": call_id, \"success\": success, \"data\": result}\n-                    )\n-            except Exception as ex:\n-                print(str(ex), flush=True)\n-                await self._send_json(\n-                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n-                )\n-\n-        async def _send_json(self, obj):\n-            await self.ws.send_str(json.dumps(obj, default=str))\n-\n-        async def get_online_users(self, channel_uid):\n-            self._require_login()\n-\n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_online_users(channel_uid)\n-            ]\n-\n-        async def echo(self, obj):\n-            await self.ws.send_json(obj)\n-            return \"noresponse\"\n-\n-        async def get_users(self, channel_uid):\n-            self._require_login()\n-\n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_users(channel_uid)\n-            ]\n-\n-        async def ping(self, callId, *args):\n-            if self.user_uid:\n-                user = await self.services.user.get(uid=self.user_uid)\n-                user[\"last_ping\"] = now()\n-                await self.services.user.save(user)\n-            return {\"pong\": args}\n-\n-    async def get(self):\n-        ws = web.WebSocketResponse()\n-        await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\"):\n-            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.request.session.get(\"uid\"),\n-                deleted_at=None,\n-                is_banned=False,\n-            ):\n-                await self.services.socket.subscribe(\n-                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n-                )\n-        rpc = RPCView.RPCApi(self, ws)\n-        async for msg in ws:\n-            if msg.type == web.WSMsgType.TEXT:\n-                try:\n-                    async with Profiler():\n-                        await rpc(msg.json())\n-                except Exception as ex:\n-                    print(\"Deleting socket\", ex, flush=True)\n-                    await self.services.socket.delete(ws)\n-                    break\n-            elif msg.type == web.WSMsgType.ERROR:\n-                pass\n-            elif msg.type == web.WSMsgType.CLOSE:\n-                pass\n-        return ws\n+\tclass RPCApi:\n+\t\tdef __init__(A,view,ws):A.view=view;A.app=A.view.app;A.services=A.app.services;A.ws=ws\n+\t\t@property\n+\t\tdef user_uid(self):return self.view.session.get(_A)\n+\t\t@property\n+\t\tdef request(self):return self.view.request\n+\t\tdef _require_login(A):\n+\t\t\tif not A.is_logged_in:raise Exception('Not logged in')\n+\t\t@property\n+\t\tdef is_logged_in(self):return self.view.session.get(_I,_C)\n+\t\tasync def mark_as_read(A,channel_uid):A._require_login();await A.services.channel_member.mark_as_read(channel_uid,A.user_uid);return _D\n+\t\tasync def login(A,username,password):\n+\t\t\tD=username;E=await A.services.user.validate_login(D,password)\n+\t\t\tif not E:raise Exception('Invalid username or password')\n+\t\t\tB=await A.services.user.get(username=D);A.view.session[_A]=B[_A];A.view.session[_I]=_D;A.view.session[_B]=B[_B];A.view.session['user_nick']=B[_F];C=B.record;del C[_J];del C[_L];await A.services.socket.add(A.ws,A.view.request.session.get(_A))\n+\t\t\tasync for F in A.services.channel_member.find(user_uid=A.view.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(A.ws,F[_H],A.view.request.session.get(_A))\n+\t\t\treturn C\n+\t\tasync def search_user(A,query):A._require_login();return[A[_B]for A in await A.services.user.search(query)]\n+\t\tasync def get_user(C,user_uid):\n+\t\t\tA=user_uid;C._require_login()\n+\t\t\tif not A:A=C.user_uid\n+\t\t\tD=await C.services.user.get(uid=A);B=D.record;del B[_J];del B[_L]\n+\t\t\tif A!=D[_A]:del B['email']\n+\t\t\treturn B\n+\t\tasync def get_messages(A,channel_uid,offset=0,timestamp=_E):\n+\t\t\tA._require_login();B=[]\n+\t\t\tfor C in await A.services.channel_message.offset(channel_uid,offset or 0,timestamp or _E):D=await A.services.channel_message.to_extended_dict(C);B.append(D)\n+\t\t\treturn B\n+\t\tasync def get_channels(B):\n+\t\t\tD='is_read_only';E='is_moderator';F='tag';G='color';C='new_count';B._require_login();H=[]\n+\t\t\tasync for A in B.services.channel_member.find(user_uid=B.user_uid,is_banned=_C):\n+\t\t\t\tI=await B.services.channel.get(uid=A[_H]);J=await I.get_last_message();K=_E\n+\t\t\t\tif J:L=await J.get_user();K=L[G]\n+\t\t\t\tH.append({'name':A['label'],_A:A[_H],F:I[F],C:A[C],E:A[E],D:A[D],C:A[C],G:K})\n+\t\t\treturn H\n+\t\tasync def send_message(A,channel_uid,message):A._require_login();await A.services.chat.send(A.user_uid,channel_uid,message);return _D\n+\t\tasync def echo(A,*B):A._require_login();return B\n+\t\tasync def query(B,*C):\n+\t\t\tB._require_login();E=C[0];D=E.lower()\n+\t\t\tif any(A in D for A in['drop','alter','update','delete','replace','insert','truncate'])and'select'not in D:raise Exception(_K)\n+\t\t\tF=[dict(A)async for A in B.services.channel.query(C[0])]\n+\t\t\tfor A in F:\n+\t\t\t\ttry:del A['email']\n+\t\t\t\texcept KeyError:pass\n+\t\t\t\ttry:del A[_J]\n+\t\t\t\texcept KeyError:pass\n+\t\t\t\ttry:del A['message']\n+\t\t\t\texcept:pass\n+\t\t\t\ttry:del A['html']\n+\t\t\t\texcept:pass\n+\t\t\treturn[dict(A)async for A in B.services.channel.query(C[0])]\n+\t\tasync def __call__(A,data):\n+\t\t\tI='success';E='data';F=data;B='callId'\n+\t\t\ttry:\n+\t\t\t\tG=F.get(B);C=F.get('method')\n+\t\t\t\tif C.startswith('_'):raise Exception(_K)\n+\t\t\t\tL=F.get('args')or[]\n+\t\t\t\tif hasattr(super(),C)or not hasattr(A,C):return await A._send_json({B:G,E:_K})\n+\t\t\t\tJ=getattr(A,C.replace('.','_'),_E)\n+\t\t\t\tif not J:raise Exception('Method not found')\n+\t\t\t\tK=_D\n+\t\t\t\ttry:H=await J(*L)\n+\t\t\t\texcept Exception as D:H={'exception':str(D),'traceback':traceback.format_exc()};K=_C\n+\t\t\t\tif H!=_M:await A._send_json({B:G,I:K,E:H})\n+\t\t\texcept Exception as D:print(str(D),flush=_D);await A._send_json({B:G,I:_C,E:str(D)})\n+\t\tasync def _send_json(A,obj):await A.ws.send_str(json.dumps(obj,default=str))\n+\t\tasync def get_online_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_online_users(channel_uid)]\n+\t\tasync def echo(A,obj):await A.ws.send_json(obj);return _M\n+\t\tasync def get_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_users(channel_uid)]\n+\t\tasync def ping(A,callId,*C):\n+\t\t\tif A.user_uid:B=await A.services.user.get(uid=A.user_uid);B[_G]=now();await A.services.user.save(B)\n+\t\t\treturn{'pong':C}\n+\tasync def get(A):\n+\t\tB=web.WebSocketResponse();await B.prepare(A.request)\n+\t\tif A.request.session.get(_I):\n+\t\t\tawait A.services.socket.add(B,A.request.session.get(_A))\n+\t\t\tasync for D in A.services.channel_member.find(user_uid=A.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(B,D[_H],A.request.session.get(_A))\n+\t\tE=RPCView.RPCApi(A,B)\n+\t\tasync for C in B:\n+\t\t\tif C.type==web.WSMsgType.TEXT:\n+\t\t\t\ttry:\n+\t\t\t\t\tasync with Profiler():await E(C.json())\n+\t\t\t\texcept Exception as F:print('Deleting socket',F,flush=_D);await A.services.socket.delete(B);break\n+\t\t\telif C.type==web.WSMsgType.ERROR:0\n+\t\t\telif C.type==web.WSMsgType.CLOSE:0\n+\t\treturn B\n\\ No newline at end of file\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 1f09a26..5e5b7e2 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,56 +1,12 @@\n-\n-\n-\n-\n-\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n-\n-\n class SearchUserView(BaseFormView):\n-    form = SearchUserForm\n-    login_required = True\n-\n-    async def get(self):\n-        users = []\n-        query = self.request.query.get(\"query\")\n-        if query:\n-            users = [user.record for user in await self.app.services.user.search(query)]\n-\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        return await self.render_template(\n-            \"search_user.html\",\n-            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n-        )\n-\n-    async def submit(self, form):\n-        if await form.is_valid:\n-            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n-        return {\"is_valid\": False}\n+\tform=SearchUserForm;login_required=True\n+\tasync def get(A):\n+\t\tC='query';D=[];B=A.request.query.get(C)\n+\t\tif B:D=[A.record for A in await A.app.services.user.search(B)]\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\tE=await A.app.services.user.get(uid=A.session.get('uid'));return await A.render_template('search_user.html',{'users':D,C:B or'','current_user':E})\n+\tasync def submit(A,form):\n+\t\tif await form.is_valid:return{'redirect_url':'/search-user.html?query='+form['username']}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex 418ef3d..fc58857 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,9 +1,4 @@\n from snek.system.view import BaseView\n-\n-\n class SettingsIndexView(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        return await self.render_template(\"settings/index.html\")\n+\tlogin_required=True\n+\tasync def get(A):return await A.render_template('settings/index.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 164c526..4a3f897 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,38 +1,13 @@\n+_C='profile'\n+_B='uid'\n+_A='nick'\n from aiohttp import web\n-\n from snek.form.settings.profile import SettingsProfileForm\n from snek.system.view import BaseFormView\n-\n-\n class SettingsProfileView(BaseFormView):\n-    form = SettingsProfileForm\n-\n-    login_required = True\n-\n-    async def get(self):\n-        form = self.form(app=self.app)\n-\n-        if self.request.path.endswith(\".json\"):\n-            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n-\n-            return web.json_response(await form.to_json())\n-\n-        profile = await self.services.user_property.get(\n-            self.session.get(\"uid\"), \"profile\"\n-        )\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        return await self.render_template(\n-            \"settings/profile.html\",\n-            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n-        )\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        user[\"nick\"] = data[\"nick\"]\n-        await self.services.user.save(user)\n-        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n-        return web.HTTPFound(\"/settings/profile.html\")\n+\tform=SettingsProfileForm;login_required=True\n+\tasync def get(A):\n+\t\tC='user';B=A.form(app=A.app)\n+\t\tif A.request.path.endswith('.json'):B[_A]=A.request[C][_A];return web.json_response(await B.to_json())\n+\t\tD=await A.services.user_property.get(A.session.get(_B),_C);E=await A.services.user.get(uid=A.session.get(_B));return await A.render_template('settings/profile.html',{'form':await B.to_json(),C:E,_C:D or''})\n+\tasync def post(A):C=await A.request.post();B=await A.services.user.get(uid=A.session.get(_B));B[_A]=C[_A];await A.services.user.save(B);await A.services.user_property.set(B[_B],_C,C[_C]);return web.HTTPFound('/settings/profile.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 093d229..1cf96fd 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -1,86 +1,37 @@\n+_F='repository'\n+_E='/settings/repositories/index.html'\n+_D='is_private'\n+_C=True\n+_B='name'\n+_A='uid'\n import asyncio\n from aiohttp import web\n-\n from snek.system.view import BaseFormView\n import pathlib\n-\n class RepositoriesIndexView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        \n-        user_uid = self.session.get(\"uid\")\n-        \n-        repositories = []\n-        async for repository in self.services.repository.find(user_uid=user_uid):\n-            repositories.append(repository.record)\n-        \n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})\n-\n-\n-\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tC=A.session.get(_A);B=[]\n+\t\tasync for D in A.services.repository.find(user_uid=C):B.append(D.record)\n+\t\tE=await A.services.user.get(uid=A.session.get(_A));return await A.render_template('settings/repositories/index.html',{'repositories':B,'user':E})\n class RepositoriesCreateView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        \n-        return await self.render_template(\"settings/repositories/create.html\")\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n+\tlogin_required=_C\n+\tasync def get(A):return await A.render_template('settings/repositories/create.html')\n+\tasync def post(A):B=await A.request.post();C=await A.services.repository.create(user_uid=A.session.get(_A),name=B[_B],is_private=int(B.get(_D,0)));return web.HTTPFound(_E)\n class RepositoriesUpdateView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-\n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        repository['is_private'] = int(data.get('is_private',0))\n-        await self.services.repository.save(repository)\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n+\t\tif not B:return web.HTTPNotFound()\n+\t\treturn await A.render_template('settings/repositories/update.html',{_F:B.record})\n+\tasync def post(A):C=await A.request.post();B=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B]);B[_D]=int(C.get(_D,0));await A.services.repository.save(B);return web.HTTPFound(_E)\n class RepositoriesDeleteView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-            \n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-\n-        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n-\n-    async def post(self):\n-        user_uid = self.session.get(\"uid\")\n-        name = self.request.match_info[\"name\"]\n-        repository = await self.services.repository.get(\n-                user_uid=user_uid, name=name\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-        await self.services.repository.delete(user_uid=user_uid, name=name)\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n+\t\tif not B:return web.HTTPNotFound()\n+\t\treturn await A.render_template('settings/repositories/delete.html',{_F:B.record})\n+\tasync def post(A):\n+\t\tB=A.session.get(_A);C=A.request.match_info[_B];D=await A.services.repository.get(user_uid=B,name=C)\n+\t\tif not D:return web.HTTPNotFound()\n+\t\tawait A.services.repository.delete(user_uid=B,name=C);return web.HTTPFound(_E)\n\\ No newline at end of file\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex 1680c5c..dbf7fc6 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,13 +1,5 @@\n import json\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class StatsView(BaseView):\n-\n-    async def get(self):\n-        data = await self.app.cache.get_stats()\n-        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n-        return web.Response(text=data, content_type=\"application/json\")\n+\tasync def get(B):A=await B.app.cache.get_stats();A=json.dumps({'total':len(A),'stats':A},default=str,indent=1);return web.Response(text=A,content_type='application/json')\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 4675572..672d20f 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,73 +1,10 @@\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class StatusView(BaseView):\n-    async def get(self):\n-        memberships = []\n-        user = {}\n-\n-        user_id = self.session.get(\"uid\")\n-        if user_id:\n-            user = await self.app.services.user.get(uid=user_id)\n-            if not user:\n-                return await self.json_response({\"error\": \"User not found\"}, status=404)\n-\n-            async for model in self.app.services.channel_member.find(\n-                user_uid=user_id, deleted_at=None, is_banned=False\n-            ):\n-                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n-                memberships.append(\n-                    {\n-                        \"name\": channel[\"label\"],\n-                        \"description\": model[\"description\"],\n-                        \"user_uid\": model[\"user_uid\"],\n-                        \"is_moderator\": model[\"is_moderator\"],\n-                        \"is_read_only\": model[\"is_read_only\"],\n-                        \"is_muted\": model[\"is_muted\"],\n-                        \"is_banned\": model[\"is_banned\"],\n-                        \"channel_uid\": model[\"channel_uid\"],\n-                        \"uid\": model[\"uid\"],\n-                    }\n-                )\n-            user = {\n-                \"username\": user[\"username\"],\n-                \"email\": user[\"email\"],\n-                \"nick\": user[\"nick\"],\n-                \"uid\": user[\"uid\"],\n-                \"color\": user[\"color\"],\n-                \"memberships\": memberships,\n-            }\n-\n-        return await self.json_response(\n-            {\n-                \"user\": user,\n-                \"cache\": await self.app.cache.create_cache_key(\n-                    self.app.cache.cache, None\n-                ),\n-            }\n-        )\n+\tasync def get(C):\n+\t\tG='color';H='nick';I='email';J='username';K='is_banned';L='is_muted';M='is_read_only';N='is_moderator';O='user_uid';P='description';E='channel_uid';D='uid';Q=[];A={};F=C.session.get(D)\n+\t\tif F:\n+\t\t\tA=await C.app.services.user.get(uid=F)\n+\t\t\tif not A:return await C.json_response({'error':'User not found'},status=404)\n+\t\t\tasync for B in C.app.services.channel_member.find(user_uid=F,deleted_at=None,is_banned=False):R=await C.app.services.channel.get(uid=B[E]);Q.append({'name':R['label'],P:B[P],O:B[O],N:B[N],M:B[M],L:B[L],K:B[K],E:B[E],D:B[D]})\n+\t\t\tA={J:A[J],I:A[I],H:A[H],D:A[D],G:A[G],'memberships':Q}\n+\t\treturn await C.json_response({'user':A,'cache':await C.app.cache.create_cache_key(C.app.cache.cache,None)})\n\\ No newline at end of file\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex d3af9b0..43c1fd1 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,54 +1,23 @@\n-import pathlib\n-\n-import aiohttp\n-\n+_B=True\n+_A='uid'\n+import pathlib,aiohttp\n from snek.system.terminal import TerminalSession\n from snek.system.view import BaseView\n-\n-\n class TerminalSocketView(BaseView):\n-\n-    login_required = True\n-\n-    user_sessions = {}\n-\n-    async def prepare_drive(self):\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n-        root.mkdir(parents=True, exist_ok=True)\n-        terminal_folder = pathlib.Path(\"terminal\")\n-        for path in terminal_folder.iterdir():\n-            destination_path = root.joinpath(path.name)\n-            if not path.is_dir():\n-                destination_path.write_bytes(path.read_bytes())\n-        return root\n-\n-    async def get(self):\n-\n-        ws = aiohttp.web.WebSocketResponse()\n-        await ws.prepare(self.request)\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        root = await self.prepare_drive()\n-\n-        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n-\n-        session = self.user_sessions.get(user[\"uid\"])\n-        if not session:\n-            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n-        session = self.user_sessions[user[\"uid\"]]\n-        await session.add_websocket(ws)\n-\n-        async for msg in ws:\n-            if msg.type == aiohttp.WSMsgType.BINARY:\n-                await session.write_input(msg.data.decode())\n-\n-        return ws\n-\n-\n+\tlogin_required=_B;user_sessions={}\n+\tasync def prepare_drive(C):\n+\t\tD=await C.services.user.get(uid=C.session.get(_A));A=pathlib.Path('drive').joinpath(D[_A]);A.mkdir(parents=_B,exist_ok=_B);E=pathlib.Path('terminal')\n+\t\tfor B in E.iterdir():\n+\t\t\tF=A.joinpath(B.name)\n+\t\t\tif not B.is_dir():F.write_bytes(B.read_bytes())\n+\t\treturn A\n+\tasync def get(A):\n+\t\tB=aiohttp.web.WebSocketResponse();await B.prepare(A.request);D=await A.services.user.get(uid=A.session.get(_A));F=await A.prepare_drive();G=f\"docker run -v ./{F}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\";C=A.user_sessions.get(D[_A])\n+\t\tif not C:A.user_sessions[D[_A]]=TerminalSession(command=G)\n+\t\tC=A.user_sessions[D[_A]];await C.add_websocket(B)\n+\t\tasync for E in B:\n+\t\t\tif E.type==aiohttp.WSMsgType.BINARY:await C.write_input(E.data.decode())\n+\t\treturn B\n class TerminalView(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        return await self.request.app.render_template(\"terminal.html\", self.request)\n+\tlogin_required=_B\n+\tasync def get(A):return await A.request.app.render_template('terminal.html',A.request)\n\\ No newline at end of file\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex bc923c6..3b7425d 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,37 +1,11 @@\n from snek.system.view import BaseView\n-\n-\n class ThreadsView(BaseView):\n-\n-    async def get(self):\n-        threads = []\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        async for channel_member in user.get_channel_members():\n-            thread = {}\n-            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n-            last_message = await channel.get_last_message()\n-            if not last_message:\n-                continue\n-\n-            thread[\"uid\"] = channel[\"uid\"]\n-            thread[\"name\"] = await channel_member.get_name()\n-            thread[\"new_count\"] = channel_member[\"new_count\"]\n-            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n-            thread[\"created_at\"] = thread[\"last_message_on\"]\n-\n-            thread[\"last_message_text\"] = last_message[\"message\"]\n-            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n-            user_last_message = await self.app.services.user.get(\n-                uid=last_message[\"user_uid\"]\n-            )\n-            if channel[\"tag\"] == \"dm\":\n-                thread[\"name_color\"] = user_last_message[\"color\"]\n-            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n-            threads.append(thread)\n-\n-        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n-\n-        return await self.render_template(\n-            \"threads.html\", {\"threads\": threads, \"user\": user}\n-        )\n+\tasync def get(B):\n+\t\tI='color';J='user_uid';K='name_color';L='new_count';F='uid';C='last_message_on';G=[];M=await B.services.user.get(uid=B.session.get(F))\n+\t\tasync for H in M.get_channel_members():\n+\t\t\tA={};D=await B.services.channel.get(uid=H['channel_uid']);E=await D.get_last_message()\n+\t\t\tif not E:continue\n+\t\t\tif D['tag']=='dm':A[K]=N[I]\n+\t\t\tA['last_message_user_color']=N[I];G.append(A)\n+\t\tG.sort(key=lambda x:x[C]or'',reverse=True);return await B.render_template('threads.html',{'threads':G,'user':M})\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex cf01948..e45d5f6 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,111 +1,19 @@\n-\n-\n-\n-\n-import pathlib\n-import uuid\n-\n-import aiofiles\n+_A='uid'\n+import pathlib,uuid,aiofiles\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class UploadView(BaseView):\n-\n-    async def get(self):\n-        uid = self.request.match_info.get(\"uid\")\n-        drive_item = await self.services.drive_item.get(uid)\n-        response = web.FileResponse(drive_item[\"path\"])\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n-        response.headers[\"Content-Disposition\"] = (\n-            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n-        )\n-        return response\n-\n-    async def post(self):\n-        reader = await self.request.multipart()\n-        files = []\n-\n-        user_uid = self.request.session.get(\"uid\")\n-\n-        upload_dir = await self.services.user.get_home_folder(user_uid)\n-        upload_dir = upload_dir.joinpath(\"upload\")\n-        upload_dir.mkdir(parents=True, exist_ok=True)\n-\n-        channel_uid = None\n-\n-        drive = await self.services.drive.get_or_create(\n-            user_uid=self.request.session.get(\"uid\")\n-        )\n-\n-        extension_types = {\n-            \".jpg\": \"image\",\n-            \".gif\": \"image\",\n-            \".png\": \"image\",\n-            \".jpeg\": \"image\",\n-            \".mp4\": \"video\",\n-            \".mp3\": \"audio\",\n-            \".pdf\": \"document\",\n-            \".doc\": \"document\",\n-            \".docx\": \"document\",\n-        }\n-\n-        while field := await reader.next():\n-            if field.name == \"channel_uid\":\n-                channel_uid = await field.text()\n-                continue\n-\n-            filename = field.filename\n-            if not filename:\n-                continue\n-\n-            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n-\n-            file_path = upload_dir.joinpath(name)\n-            files.append(file_path)\n-\n-            async with aiofiles.open(str(file_path), \"wb\") as f:\n-                while chunk := await field.read_chunk():\n-                    await f.write(chunk)\n-\n-            drive_item = await self.services.drive_item.create(\n-                drive[\"uid\"],\n-                filename,\n-                str(file_path),\n-                file_path.stat().st_size,\n-                file_path.suffix,\n-            )\n-\n-            extension = \".\" + filename.split(\".\")[-1]\n-            if extension in extension_types:\n-                extension_types[extension]\n-\n-            await self.services.drive_item.save(drive_item)\n-            response = (\n-                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            )\n-            response = (\n-                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n-            )\n-\n-            await self.services.chat.send(\n-                self.request.session.get(\"uid\"), channel_uid, response\n-            )\n-\n-        return web.json_response(\n-            {\n-                \"message\": \"Files uploaded successfully\",\n-                \"files\": [str(file) for file in files],\n-                \"channel_uid\": channel_uid,\n-            }\n-        )\n+\tasync def get(B):D=B.request.match_info.get(_A);C=await B.services.drive_item.get(D);A=web.FileResponse(C['path']);A.headers['Cache-Control']=f\"public, max-age={561540}\";A.headers['Content-Disposition']=f'attachment; filename=\"{C[\"name\"]}\"';return A\n+\tasync def post(A):\n+\t\tK='](/drive.bin/';L='channel_uid';G='document';D='image';P=await A.request.multipart();M=[];Q=A.request.session.get(_A);E=await A.services.user.get_home_folder(Q);E=E.joinpath('upload');E.mkdir(parents=True,exist_ok=True);H=None;R=await A.services.drive.get_or_create(user_uid=A.request.session.get(_A));N={'.jpg':D,'.gif':D,'.png':D,'.jpeg':D,'.mp4':'video','.mp3':'audio','.pdf':G,'.doc':G,'.docx':G}\n+\t\twhile(F:=await P.next()):\n+\t\t\tif F.name==L:H=await F.text();continue\n+\t\t\tB=F.filename\n+\t\t\tif not B:continue\n+\t\t\tS=str(uuid.uuid4())+pathlib.Path(B).suffix;C=E.joinpath(S);M.append(C)\n+\t\t\tasync with aiofiles.open(str(C),'wb')as T:\n+\t\t\t\twhile(U:=await F.read_chunk()):await T.write(U)\n+\t\t\tI=await A.services.drive_item.create(R[_A],B,str(C),C.stat().st_size,C.suffix);J='.'+B.split('.')[-1]\n+\t\t\tif J in N:N[J]\n+\t\t\tawait A.services.drive_item.save(I);O='Uploaded ['+B+K+I[_A]+')';O='['+B+K+I[_A]+J+')';await A.services.chat.send(A.request.session.get(_A),H,O)\n+\t\treturn web.json_response({'message':'Files uploaded successfully','files':[str(A)for A in M],L:H})\n\\ No newline at end of file\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex 312f7bf..ab00e1a 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -1,15 +1,3 @@\n from snek.system.view import BaseView\n-\n-\n class UserView(BaseView):\n-\n-    async def get(self):\n-        user_uid = self.request.match_info.get(\"user\")\n-        user = await self.services.user.get(uid=user_uid)\n-        profile_content = (\n-            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n-        )\n-        return await self.render_template(\n-            \"user.html\",\n-            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n-        )\n+\tasync def get(A):B='profile';C='user';D=A.request.match_info.get(C);E=await A.services.user.get(uid=D);F=await A.services.user_property.get(E['uid'],B)or'';return await A.render_template('user.html',{'user_uid':D,C:E.record,B:F})\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 111f76c..292586d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,79 +1,19 @@\n-\n-\n-\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class WebView(BaseView):\n-    login_required = True\n-\n-    async def get(self):\n-        if self.login_required and not self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/\")\n-        channel = await self.services.channel.get(\n-            uid=self.request.match_info.get(\"channel\")\n-        )\n-        if not channel:\n-            user = await self.services.user.get(\n-                uid=self.request.match_info.get(\"channel\")\n-            )\n-            if user:\n-                channel = await self.services.channel.get_dm(\n-                    self.session.get(\"uid\"), user[\"uid\"]\n-                )\n-                if channel:\n-                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        if not channel:\n-            return web.HTTPNotFound()\n-\n-        channel_member = await self.app.services.channel_member.get(\n-            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n-        )\n-        if not channel_member:\n-            return web.HTTPNotFound()\n-\n-        channel_member[\"new_count\"] = 0\n-        await self.app.services.channel_member.save(channel_member)\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        messages = [\n-            await self.app.services.channel_message.to_extended_dict(message)\n-            for message in await self.app.services.channel_message.offset(\n-                channel[\"uid\"]\n-            )\n-        ]\n-        for message in messages:\n-            await self.app.services.notification.mark_as_read(\n-                self.session.get(\"uid\"), message[\"uid\"]\n-            )\n-\n-        name = await channel_member.get_name()\n-        return await self.render_template(\n-            \"web.html\",\n-            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n-        )\n+\tlogin_required=True\n+\tasync def get(A):\n+\t\tF='channel';B='uid'\n+\t\tif A.login_required and not A.session.get('logged_in'):return web.HTTPFound('/')\n+\t\tC=await A.services.channel.get(uid=A.request.match_info.get(F))\n+\t\tif not C:\n+\t\t\tD=await A.services.user.get(uid=A.request.match_info.get(F))\n+\t\t\tif D:\n+\t\t\t\tC=await A.services.channel.get_dm(A.session.get(B),D[B])\n+\t\t\t\tif C:return web.HTTPFound('/channel/{}.html'.format(C[B]))\n+\t\tif not C:return web.HTTPNotFound()\n+\t\tE=await A.app.services.channel_member.get(user_uid=A.session.get(B),channel_uid=C[B])\n+\t\tif not E:return web.HTTPNotFound()\n+\t\tE['new_count']=0;await A.app.services.channel_member.save(E);D=await A.services.user.get(uid=A.session.get(B));G=[await A.app.services.channel_message.to_extended_dict(B)for B in await A.app.services.channel_message.offset(C[B])]\n+\t\tfor H in G:await A.app.services.notification.mark_as_read(A.session.get(B),H[B])\n+\t\tI=await E.get_name();return await A.render_template('web.html',{'name':I,F:C,'user':D,'messages':G})\n\\ No newline at end of file\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 4c57fab..0d0ae16 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,377 +1,145 @@\n-import logging\n-import pathlib\n-\n+_U='Lock-Token'\n+_T='application/xml'\n+_S='{DAV:}exclusive'\n+_R='{DAV:}lockdiscovery'\n+_Q='{DAV:}prop'\n+_P='%a, %d %b %Y %H:%M:%S GMT'\n+_O='Source not found'\n+_M='Destination'\n+_L='application/octet-stream'\n+_K='File not found'\n+_J='{DAV:}write'\n+_I='{DAV:}locktype'\n+_H='{DAV:}lockscope'\n+_G='{DAV:}href'\n+_F='Content-Type'\n+_E=True\n+_D='filename'\n+_C='Basic realm=\"WebDAV\"'\n+_B='WWW-Authenticate'\n+_A='home'\n+import logging,pathlib\n logging.basicConfig(level=logging.DEBUG)\n-import base64\n-import datetime\n-import mimetypes\n-import os\n-import shutil\n-import uuid\n-\n-import aiofiles\n-import aiohttp\n-import aiohttp.web\n+import base64,datetime,mimetypes,os,shutil,uuid,aiofiles,aiohttp,aiohttp.web\n from app.cache import time_cache_async\n from lxml import etree\n-\n-\n @aiohttp.web.middleware\n-async def debug_middleware(request, handler):\n-    print(request.method, request.path, request.headers)\n-    result = await handler(request)\n-    print(result.status)\n-    try:\n-        print(await result.text())\n-    except:\n-        pass\n-    return result\n-\n-\n+async def debug_middleware(request,handler):\n+\tA=request;print(A.method,A.path,A.headers);B=await handler(A);print(B.status)\n+\ttry:print(await B.text())\n+\texcept:pass\n+\treturn B\n class WebdavApplication(aiohttp.web.Application):\n-    def __init__(self, parent, *args, **kwargs):\n-        middlewares = [debug_middleware]\n-\n-        super().__init__(middlewares=middlewares, *args, **kwargs)\n-        self.locks = {}\n-\n-        self.relative_url = \"/webdav\"\n-\n-        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n-        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n-        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n-        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n-        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n-        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n-        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n-        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n-        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n-        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n-        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n-        self.parent = parent\n-\n-    @property\n-    def db(self):\n-        return self.parent.db\n-\n-    @property\n-    def services(self):\n-        return self.parent.services\n-\n-    async def authenticate(self, request):\n-        auth_header = request.headers.get(\"Authorization\", \"\")\n-        if not auth_header.startswith(\"Basic \"):\n-            return False\n-        encoded_creds = auth_header.split(\"Basic \")[1]\n-        decoded_creds = base64.b64decode(encoded_creds).decode()\n-        username, password = decoded_creds.split(\":\", 1)\n-        request[\"user\"] = await self.services.user.authenticate(\n-            username=username, password=password\n-        )\n-        try:\n-            request[\"home\"] = await self.services.user.get_home_folder(\n-                request[\"user\"][\"uid\"]\n-            )\n-        except Exception:\n-            pass\n-        return request[\"user\"]\n-\n-    async def handle_get(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request[\"home\"] / requested_path\n-\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"File not found\")\n-\n-        if abs_path.is_dir():\n-            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n-\n-        content_type, _ = mimetypes.guess_type(str(abs_path))\n-        content_type = content_type or \"application/octet-stream\"\n-\n-        return aiohttp.web.FileResponse(\n-            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n-        )\n-\n-    async def handle_put(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        file_path.parent.mkdir(parents=True, exist_ok=True)\n-        async with aiofiles.open(file_path, \"wb\") as f:\n-            while chunk := await request.content.read(1024):\n-                await f.write(chunk)\n-        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n-\n-    async def handle_delete(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        if file_path.is_file():\n-            file_path.unlink()\n-            return aiohttp.web.Response(status=204)\n-        elif file_path.is_dir():\n-            shutil.rmtree(file_path)\n-            return aiohttp.web.Response(status=204)\n-        return aiohttp.web.Response(status=404, text=\"Not found\")\n-\n-    async def handle_mkcol(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n-        if dir_path.exists():\n-            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n-        dir_path.mkdir(parents=True, exist_ok=True)\n-        return aiohttp.web.Response(status=201, text=\"Directory created\")\n-\n-    async def handle_move(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        src_path = request[\"home\"] / request.match_info[\"filename\"]\n-        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n-        )\n-        if not src_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Source not found\")\n-        shutil.move(str(src_path), str(dest_path))\n-        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n-\n-    async def handle_copy(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        src_path = request[\"home\"] / request.match_info[\"filename\"]\n-        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n-        )\n-        if not src_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Source not found\")\n-        if src_path.is_file():\n-            shutil.copy2(str(src_path), str(dest_path))\n-        else:\n-            shutil.copytree(str(src_path), str(dest_path))\n-        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n-\n-    async def handle_options(self, request):\n-        headers = {\n-            \"DAV\": \"1, 2\",\n-            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n-        }\n-        return aiohttp.web.Response(status=200, headers=headers)\n-\n-    def get_current_utc_time(self, filepath):\n-        if filepath.exists():\n-            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n-        else:\n-            modified_time = datetime.datetime.utcnow()\n-        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n-            \"%a, %d %b %Y %H:%M:%S GMT\"\n-        )\n-\n-    @time_cache_async(10)\n-    async def get_file_size(self, path):\n-        loop = self.parent.loop\n-        stat = await loop.run_in_executor(None, os.stat, path)\n-        return stat.st_size\n-\n-    @time_cache_async(10)\n-    async def get_directory_size(self, directory):\n-        total_size = 0\n-        for dirpath, _, filenames in os.walk(directory):\n-            for f in filenames:\n-                fp = pathlib.Path(dirpath) / f\n-                if fp.exists():\n-                    total_size += await self.get_file_size(str(fp))\n-        return total_size\n-\n-    @time_cache_async(30)\n-    async def get_disk_free_space(self, path=\"/\"):\n-        loop = self.parent.loop\n-        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n-        return statvfs.f_bavail * statvfs.f_frsize\n-\n-    async def create_node(self, request, response_xml, full_path, depth):\n-        abs_path = pathlib.Path(full_path)\n-        relative_path = str(full_path.relative_to(request[\"home\"]))\n-\n-        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n-        href_path = href_path.replace(\"./\", \"/\")\n-\n-        response = etree.SubElement(response_xml, \"{DAV:}response\")\n-        href = etree.SubElement(response, \"{DAV:}href\")\n-        href.text = href_path\n-        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n-        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n-        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n-        if full_path.is_dir():\n-            etree.SubElement(res_type, \"{DAV:}collection\")\n-        creation_date, last_modified = self.get_current_utc_time(full_path)\n-        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n-        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-            await self.get_file_size(full_path)\n-            if full_path.is_file()\n-            else await self.get_directory_size(full_path)\n-        )\n-        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            await self.get_disk_free_space(request[\"home\"])\n-        )\n-        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n-        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n-        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n-        mimetype, _ = mimetypes.guess_type(full_path.name)\n-        if full_path.is_file():\n-            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                await self.get_file_size(full_path)\n-                if full_path.is_file()\n-                else await self.get_directory_size(full_path)\n-            )\n-        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n-        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n-        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type_1, \"{DAV:}write\")\n-        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n-        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type_2, \"{DAV:}write\")\n-        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n-\n-        if abs_path.is_dir() and depth > 0:\n-            for item in abs_path.iterdir():\n-                await self.create_node(request, response_xml, item, depth - 1)\n-\n-    async def handle_propfind(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        depth = 0\n-        try:\n-            depth = int(request.headers.get(\"Depth\", \"0\"))\n-        except ValueError:\n-            pass\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-\n-        abs_path = request[\"home\"] / requested_path\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n-        nsmap = {\"D\": \"DAV:\"}\n-        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n-\n-        await self.create_node(request, response_xml, abs_path, depth)\n-\n-        xml_output = etree.tostring(\n-            response_xml, encoding=\"utf-8\", xml_declaration=True\n-        ).decode()\n-        return aiohttp.web.Response(\n-            status=207, text=xml_output, content_type=\"application/xml\"\n-        )\n-\n-    async def handle_proppatch(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n-\n-    async def handle_lock(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        resource = request.match_info.get(\"filename\", \"/\")\n-        lock_id = str(uuid.uuid4())\n-        self.locks[resource] = lock_id\n-        xml_response = await self.generate_lock_response(lock_id)\n-        headers = {\n-            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n-            \"Content-Type\": \"application/xml\",\n-        }\n-        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n-\n-    async def handle_unlock(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        resource = request.match_info.get(\"filename\", \"/\")\n-        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n-            \"opaquelocktoken:\", \"\"\n-        )[1:-1]\n-        if self.locks.get(resource) == lock_token:\n-            del self.locks[resource]\n-            return aiohttp.web.Response(status=204)\n-        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n-\n-    async def generate_lock_response(self, lock_id):\n-        nsmap = {\"D\": \"DAV:\"}\n-        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n-        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n-        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n-        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type, \"{DAV:}write\")\n-        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n-        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n-        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n-        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n-        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n-        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n-        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n-        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n-\n-    def get_last_modified(self, path):\n-        if not path.exists():\n-            return None\n-        timestamp = path.stat().st_mtime\n-        dt = datetime.datetime.utcfromtimestamp(timestamp)\n-        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n-\n-    async def handle_head(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request[\"home\"] / requested_path\n-\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"File not found\")\n-\n-        if abs_path.is_dir():\n-            return aiohttp.web.Response(\n-                status=403, text=\"Cannot get metadata for a directory\"\n-            )\n-\n-        content_type, _ = mimetypes.guess_type(str(abs_path))\n-        content_type = content_type or \"application/octet-stream\"\n-        file_size = abs_path.stat().st_size\n-\n-        headers = {\n-            \"Content-Type\": content_type,\n-            \"Content-Length\": str(file_size),\n-            \"Last-Modified\": self.get_last_modified(abs_path),\n-        }\n-\n-        return aiohttp.web.Response(status=200, headers=headers)\n+\tdef __init__(A,parent,*C,**D):B='/{filename:.*}';E=[debug_middleware];super().__init__(*C,middlewares=E,**D);A.locks={};A.relative_url='/webdav';A.router.add_route('OPTIONS',B,A.handle_options);A.router.add_route('GET',B,A.handle_get);A.router.add_route('PUT',B,A.handle_put);A.router.add_route('DELETE',B,A.handle_delete);A.router.add_route('MKCOL',B,A.handle_mkcol);A.router.add_route('MOVE',B,A.handle_move);A.router.add_route('COPY',B,A.handle_copy);A.router.add_route('PROPFIND',B,A.handle_propfind);A.router.add_route('PROPPATCH',B,A.handle_proppatch);A.router.add_route('LOCK',B,A.handle_lock);A.router.add_route('UNLOCK',B,A.handle_unlock);A.parent=parent\n+\t@property\n+\tdef db(self):return self.parent.db\n+\t@property\n+\tdef services(self):return self.parent.services\n+\tasync def authenticate(C,request):\n+\t\tD='Basic ';B='user';A=request;E=A.headers.get('Authorization','')\n+\t\tif not E.startswith(D):return False\n+\t\tF=E.split(D)[1];G=base64.b64decode(F).decode();H,I=G.split(':',1);A[B]=await C.services.user.authenticate(username=H,password=I)\n+\t\ttry:A[_A]=await C.services.user.get_home_folder(A[B]['uid'])\n+\t\texcept Exception:pass\n+\t\treturn A[B]\n+\tasync def handle_get(D,request):\n+\t\tB=request\n+\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n+\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n+\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot download a directory')\n+\t\tC,F=mimetypes.guess_type(str(A));C=C or _L;return aiohttp.web.FileResponse(path=str(A),headers={_F:C},chunk_size=8192)\n+\tasync def handle_put(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];B.parent.mkdir(parents=_E,exist_ok=_E)\n+\t\tasync with aiofiles.open(B,'wb')as D:\n+\t\t\twhile(E:=await A.content.read(1024)):await D.write(E)\n+\t\treturn aiohttp.web.Response(status=201,text='File uploaded')\n+\tasync def handle_delete(C,request):\n+\t\tB=request\n+\t\tif not await C.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tA=B[_A]/B.match_info[_D]\n+\t\tif A.is_file():A.unlink();return aiohttp.web.Response(status=204)\n+\t\telif A.is_dir():shutil.rmtree(A);return aiohttp.web.Response(status=204)\n+\t\treturn aiohttp.web.Response(status=404,text='Not found')\n+\tasync def handle_mkcol(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D]\n+\t\tif B.exists():return aiohttp.web.Response(status=405,text='Directory already exists')\n+\t\tB.mkdir(parents=_E,exist_ok=_E);return aiohttp.web.Response(status=201,text='Directory created')\n+\tasync def handle_move(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];D=A[_A]/A.headers.get(_M,'').replace(_N,'')\n+\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n+\t\tshutil.move(str(B),str(D));return aiohttp.web.Response(status=201,text='Moved successfully')\n+\tasync def handle_copy(D,request):\n+\t\tA=request\n+\t\tif not await D.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];C=A[_A]/A.headers.get(_M,'').replace(_N,'')\n+\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n+\t\tif B.is_file():shutil.copy2(str(B),str(C))\n+\t\telse:shutil.copytree(str(B),str(C))\n+\t\treturn aiohttp.web.Response(status=201,text='Copied successfully')\n+\tasync def handle_options(B,request):A={'DAV':'1, 2','Allow':'OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH'};return aiohttp.web.Response(status=200,headers=A)\n+\tdef get_current_utc_time(C,filepath):\n+\t\tB=filepath\n+\t\tif B.exists():A=datetime.datetime.utcfromtimestamp(B.stat().st_mtime)\n+\t\telse:A=datetime.datetime.utcnow()\n+\t\treturn A.strftime('%Y-%m-%dT%H:%M:%SZ'),A.strftime(_P)\n+\t@time_cache_async(10)\n+\tasync def get_file_size(self,path):A=self.parent.loop;B=await A.run_in_executor(None,os.stat,path);return B.st_size\n+\t@time_cache_async(10)\n+\tasync def get_directory_size(self,directory):\n+\t\tA=0\n+\t\tfor(C,F,D)in os.walk(directory):\n+\t\t\tfor E in D:\n+\t\t\t\tB=pathlib.Path(C)/E\n+\t\t\t\tif B.exists():A+=await self.get_file_size(str(B))\n+\t\treturn A\n+\t@time_cache_async(30)\n+\tasync def get_disk_free_space(self,path='/'):B=self.parent.loop;A=await B.run_in_executor(None,os.statvfs,path);return A.f_bavail*A.f_frsize\n+\tasync def create_node(C,request,response_xml,full_path,depth):\n+\t\tif A.is_dir():etree.SubElement(Q,'{DAV:}collection')\n+\t\tR,S=C.get_current_utc_time(A);etree.SubElement(B,'{DAV:}creationdate').text=R;etree.SubElement(B,'{DAV:}quota-used-bytes').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A));etree.SubElement(B,'{DAV:}quota-available-bytes').text=str(await C.get_disk_free_space(E[_A]));etree.SubElement(B,'{DAV:}getlastmodified').text=S;etree.SubElement(B,'{DAV:}displayname').text=A.name;etree.SubElement(B,_R);T,Z=mimetypes.guess_type(A.name)\n+\t\tif A.is_file():etree.SubElement(B,'{DAV:}contenttype').text=T;etree.SubElement(B,'{DAV:}getcontentlength').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A))\n+\t\tL=etree.SubElement(B,'{DAV:}supportedlock');M=etree.SubElement(L,F);U=etree.SubElement(M,_H);etree.SubElement(U,_S);V=etree.SubElement(M,_I);etree.SubElement(V,_J);N=etree.SubElement(L,F);W=etree.SubElement(N,_H);etree.SubElement(W,'{DAV:}shared');X=etree.SubElement(N,_I);etree.SubElement(X,_J);etree.SubElement(K,'{DAV:}status').text='HTTP/1.1 200 OK'\n+\t\tif I.is_dir()and G>0:\n+\t\t\tfor Y in I.iterdir():await C.create_node(E,H,Y,G-1)\n+\tasync def handle_propfind(B,request):\n+\t\tA=request\n+\t\tif not await B.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tC=0\n+\t\ttry:C=int(A.headers.get('Depth','0'))\n+\t\texcept ValueError:pass\n+\t\tF=A.match_info.get(_D,'');D=A[_A]/F\n+\t\tif not D.exists():return aiohttp.web.Response(status=404,text='Directory not found')\n+\t\tG={'D':'DAV:'};E=etree.Element('{DAV:}multistatus',nsmap=G);await B.create_node(A,E,D,C);H=etree.tostring(E,encoding='utf-8',xml_declaration=_E).decode();return aiohttp.web.Response(status=207,text=H,content_type=_T)\n+\tasync def handle_proppatch(A,request):\n+\t\tif not await A.authenticate(request):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\treturn aiohttp.web.Response(status=207,text='PROPPATCH OK (Not Implemented)')\n+\tasync def handle_lock(A,request):\n+\t\tC=request\n+\t\tif not await A.authenticate(C):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tD=C.match_info.get(_D,'/');B=str(uuid.uuid4());A.locks[D]=B;E=await A.generate_lock_response(B);F={_U:f\"opaquelocktoken:{B}\",_F:_T};return aiohttp.web.Response(text=E,headers=F,status=200)\n+\tasync def handle_unlock(A,request):\n+\t\tB=request\n+\t\tif not await A.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tC=B.match_info.get(_D,'/');D=B.headers.get(_U,'').replace('opaquelocktoken:','')[1:-1]\n+\t\tif A.locks.get(C)==D:del A.locks[C];return aiohttp.web.Response(status=204)\n+\t\treturn aiohttp.web.Response(status=400,text='Invalid Lock Token')\n+\tasync def generate_lock_response(J,lock_id):B=lock_id;D={'D':'DAV:'};C=etree.Element(_Q,nsmap=D);E=etree.SubElement(C,_R);A=etree.SubElement(E,'{DAV:}activelock');F=etree.SubElement(A,_I);etree.SubElement(F,_J);G=etree.SubElement(A,_H);etree.SubElement(G,_S);etree.SubElement(A,'{DAV:}depth').text='Infinity';H=etree.SubElement(A,'{DAV:}owner');etree.SubElement(H,_G).text=B;etree.SubElement(A,'{DAV:}timeout').text='Infinite';I=etree.SubElement(A,'{DAV:}locktoken');etree.SubElement(I,_G).text=f\"opaquelocktoken:{B}\";return etree.tostring(C,pretty_print=_E,encoding='utf-8').decode()\n+\tdef get_last_modified(C,path):\n+\t\tif not path.exists():return\n+\t\tA=path.stat().st_mtime;B=datetime.datetime.utcfromtimestamp(A);return B.strftime(_P)\n+\tasync def handle_head(D,request):\n+\t\tB=request\n+\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n+\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n+\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot get metadata for a directory')\n+\t\tC,H=mimetypes.guess_type(str(A));C=C or _L;F=A.stat().st_size;G={_F:C,'Content-Length':str(F),'Last-Modified':D.get_last_modified(A)};return aiohttp.web.Response(status=200,headers=G)\n\\ No newline at end of file\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex aab17e4..a2087be 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,78 +1,25 @@\n-import asyncio\n-import logging\n-import os\n-\n-import asyncssh\n-\n+_A=True\n+import asyncio,logging,os,asyncssh\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n-USERNAME = \"test\"\n-PASSWORD = \"woeii\"\n-HOST = \"localhost\"\n-PORT = 2225\n-\n-\n+SFTP_ROOT='.'\n+USERNAME='test'\n+PASSWORD='woeii'\n+HOST='localhost'\n+PORT=2225\n class MySFTPServer(asyncssh.SFTPServer):\n-    def __init__(self, chan):\n-        super().__init__(chan)\n-        self.root = os.path.abspath(SFTP_ROOT)\n-\n-    async def stat(self, path):\n-        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().stat(full_path)\n-\n-    async def open(self, path, flags, attrs):\n-        \"\"\"Handles file open requests\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().open(full_path, flags, attrs)\n-\n-    async def listdir(self, path):\n-        \"\"\"Handles directory listing\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().listdir(full_path)\n-\n-\n+\tdef __init__(A,chan):super().__init__(chan);A.root=os.path.abspath(SFTP_ROOT)\n+\tasync def stat(A,path):\"Handles 'stat' command from SFTP client\";B=os.path.join(A.root,path.lstrip('/'));return await super().stat(B)\n+\tasync def open(A,path,flags,attrs):'Handles file open requests';B=os.path.join(A.root,path.lstrip('/'));return await super().open(B,flags,attrs)\n+\tasync def listdir(A,path):'Handles directory listing';B=os.path.join(A.root,path.lstrip('/'));return await super().listdir(B)\n class MySSHServer(asyncssh.SSHServer):\n-    \"\"\"Custom SSH server to handle authentication\"\"\"\n-\n-    def connection_made(self, conn):\n-        print(f\"New connection from {conn.get_extra_info('peername')}\")\n-\n-    def connection_lost(self, exc):\n-        print(\"Client disconnected\")\n-\n-    def begin_auth(self, username):\n-\n-    def password_auth_supported(self):\n-\n-    def validate_password(self, username, password):\n-        print(username, password)\n-\n-        return True\n-        return username == USERNAME and password == PASSWORD\n-\n-\n-async def start_sftp_server():\n-\n-    await asyncssh.create_server(\n-        lambda: MySSHServer(),\n-        host=HOST,\n-        port=PORT,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=MySFTPServer,\n-    )\n-    print(f\"SFTP server running on {HOST}:{PORT}\")\n-\n-\n-if __name__ == \"__main__\":\n-    try:\n-        asyncio.run(start_sftp_server())\n-    except (OSError, asyncssh.Error) as e:\n-        print(f\"Error starting SFTP server: {e}\")\n+\t'Custom SSH server to handle authentication'\n+\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n+\tdef connection_lost(A,exc):print('Client disconnected')\n+\tdef begin_auth(A,username):return _A\n+\tdef password_auth_supported(A):return _A\n+\tdef validate_password(C,username,password):A=password;B=username;print(B,A);return _A;return B==USERNAME and A==PASSWORD\n+async def start_sftp_server():os.makedirs(SFTP_ROOT,exist_ok=_A);await asyncssh.create_server(lambda:MySSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=MySFTPServer);print(f\"SFTP server running on {HOST}:{PORT}\");await asyncio.Future()\n+if __name__=='__main__':\n+\ttry:asyncio.run(start_sftp_server())\n+\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SFTP server: {e}\")\n\\ No newline at end of file\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 2fa26a7..8879a05 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,77 +1,28 @@\n-import asyncio\n-import os\n-\n-import asyncssh\n-\n-HOST = \"0.0.0.0\"\n-PORT = 2225\n-USERNAME = \"user\"\n-PASSWORD = \"password\"\n-\n-\n+import asyncio,os,asyncssh\n+HOST='0.0.0.0'\n+PORT=2225\n+USERNAME='user'\n+PASSWORD='password'\n+SHELL='/bin/sh'\n class CustomSSHServer(asyncssh.SSHServer):\n-    def connection_made(self, conn):\n-        print(f\"New connection from {conn.get_extra_info('peername')}\")\n-\n-    def connection_lost(self, exc):\n-        print(\"Client disconnected\")\n-\n-    def password_auth_supported(self):\n-        return True\n-\n-    def validate_password(self, username, password):\n-        return username == USERNAME and password == PASSWORD\n-\n-\n+\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n+\tdef connection_lost(A,exc):print('Client disconnected')\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(A,username,password):return username==USERNAME and password==PASSWORD\n async def custom_bash_process(process):\n-    \"\"\"Spawns a custom bash shell process\"\"\"\n-    env = os.environ.copy()\n-    env[\"TERM\"] = \"xterm-256color\"\n-\n-    bash_proc = await asyncio.create_subprocess_exec(\n-        SHELL,\n-        \"-i\",\n-        stdin=asyncio.subprocess.PIPE,\n-        stdout=asyncio.subprocess.PIPE,\n-        stderr=asyncio.subprocess.PIPE,\n-        env=env,\n-    )\n-\n-    async def read_output():\n-        while True:\n-            data = await bash_proc.stdout.read(1)\n-            if not data:\n-                break\n-            process.stdout.write(data)\n-\n-    async def read_input():\n-        while True:\n-            data = await process.stdin.read(1)\n-            if not data:\n-                break\n-            bash_proc.stdin.write(data)\n-\n-    await asyncio.gather(read_output(), read_input())\n-\n-\n-async def start_ssh_server():\n-    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n-    await asyncssh.create_server(\n-        lambda: CustomSSHServer(),\n-        host=HOST,\n-        port=PORT,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=custom_bash_process,\n-    )\n-    print(f\"SSH server running on {HOST}:{PORT}\")\n-\n-\n-if __name__ == \"__main__\":\n-    try:\n-        asyncio.run(start_ssh_server())\n-    except (OSError, asyncssh.Error) as e:\n-        print(f\"Error starting SSH server: {e}\")\n+\t'Spawns a custom bash shell process';A=process;B=os.environ.copy();B['TERM']='xterm-256color';C=await asyncio.create_subprocess_exec(SHELL,'-i',stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE,env=B)\n+\tasync def D():\n+\t\twhile True:\n+\t\t\tB=await C.stdout.read(1)\n+\t\t\tif not B:break\n+\t\t\tA.stdout.write(B)\n+\tasync def E():\n+\t\twhile True:\n+\t\t\tB=await A.stdin.read(1)\n+\t\t\tif not B:break\n+\t\t\tC.stdin.write(B)\n+\tawait asyncio.gather(D(),E())\n+async def start_ssh_server():'Starts the AsyncSSH server with Bash';await asyncssh.create_server(lambda:CustomSSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=custom_bash_process);print(f\"SSH server running on {HOST}:{PORT}\");await asyncio.Future()\n+if __name__=='__main__':\n+\ttry:asyncio.run(start_ssh_server())\n+\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SSH server: {e}\")\n\\ No newline at end of file\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex 4a09452..ef35691 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -1,74 +1,17 @@\n-\n-\n-import asyncio\n-import sys\n-\n-import asyncssh\n-\n-\n-async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-\n-    width, height, pixwidth, pixheight = process.term_size\n-\n-    process.stdout.write(\n-        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n-    )\n-    if pixwidth and pixheight:\n-        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n-    process.stdout.write(\"\\nTry resizing your window!\\n\")\n-\n-    while not process.stdin.at_eof():\n-        try:\n-            await process.stdin.read()\n-        except asyncssh.TerminalSizeChanged as exc:\n-            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n-            if exc.pixwidth and exc.pixheight:\n-                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n-            process.stdout.write(\"\\n\")\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.listen(\n-        \"\",\n-        2230,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+import asyncio,sys,asyncssh\n+async def handle_client(process):\n+\tA=process;E,F,C,D=A.term_size;A.stdout.write(f\"Terminal type: {A.term_type}, size: {E}x{F}\")\n+\tif C and D:A.stdout.write(f\" ({C}x{D} pixels)\")\n+\tA.stdout.write('\\nTry resizing your window!\\n')\n+\twhile not A.stdin.at_eof():\n+\t\ttry:await A.stdin.read()\n+\t\texcept asyncssh.TerminalSizeChanged as B:\n+\t\t\tA.stdout.write(f\"New window size: {B.width}x{B.height}\")\n+\t\t\tif B.pixwidth and B.pixheight:A.stdout.write(f\" ({B.pixwidth}x{B.pixheight} pixels)\")\n+\t\t\tA.stdout.write('\\n')\n+async def start_server():await asyncssh.listen('',2230,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex 187722c..eb4fe72 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -1,90 +1,24 @@\n-\n-\n-import asyncio\n-import sys\n+import asyncio,sys\n from typing import Optional\n-\n-import asyncssh\n-import bcrypt\n-\n-passwords = {\n-    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n-}\n-\n-\n-def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-    username = process.get_extra_info(\"username\")\n-    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n-\n-\n+import asyncssh,bcrypt\n+passwords={'guest':b'','user':bcrypt.hashpw(b'user',bcrypt.gensalt())}\n+def handle_client(process):A=process;B=A.get_extra_info('username');A.stdout.write(f\"Welcome to my SSH server, {B}!\\n\")\n class MySSHServer(asyncssh.SSHServer):\n-    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n-        peername = conn.get_extra_info(\"peername\")[0]\n-        print(f\"SSH connection received from {peername}.\")\n-\n-    def connection_lost(self, exc: Optional[Exception]) -> None:\n-        if exc:\n-            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n-        else:\n-            print(\"SSH connection closed.\")\n-\n-    def begin_auth(self, username: str) -> bool:\n-        return passwords.get(username) != b\"\"\n-\n-    def password_auth_supported(self) -> bool:\n-        return True\n-\n-    def validate_password(self, username: str, password: str) -> bool:\n-        if username not in passwords:\n-            return False\n-        pw = passwords[username]\n-        if not password and not pw:\n-            return True\n-        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.create_server(\n-        MySSHServer,\n-        \"\",\n-        2231,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+\tdef connection_made(B,conn):A=conn.get_extra_info('peername')[0];print(f\"SSH connection received from {A}.\")\n+\tdef connection_lost(A,exc):\n+\t\tif exc:print('SSH connection error: '+str(exc),file=sys.stderr)\n+\t\telse:print('SSH connection closed.')\n+\tdef begin_auth(A,username):return passwords.get(username)!=b''\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(D,username,password):\n+\t\tA=password;B=username\n+\t\tif B not in passwords:return False\n+\t\tC=passwords[B]\n+\t\tif not A and not C:return True\n+\t\treturn bcrypt.checkpw(A.encode('utf-8'),C)\n+async def start_server():await asyncssh.create_server(MySSHServer,'',2231,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex cfd5d21..39f45e3 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -1,112 +1,28 @@\n-\n-\n-import asyncio\n-import sys\n-from typing import List, cast\n-\n+import asyncio,sys\n+from typing import List,cast\n import asyncssh\n-\n-\n class ChatClient:\n-    _clients: List[\"ChatClient\"] = []\n-\n-    def __init__(self, process: asyncssh.SSHServerProcess):\n-        self._process = process\n-\n-    @classmethod\n-    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n-        await cls(process).run()\n-\n-    async def readline(self) -> str:\n-        return cast(str, self._process.stdin.readline())\n-\n-    def write(self, msg: str) -> None:\n-        self._process.stdout.write(msg)\n-\n-    def broadcast(self, msg: str) -> None:\n-        for client in self._clients:\n-            if client != self:\n-                client.write(msg)\n-\n-    def begin_auth(self, username: str) -> bool:\n-\n-    def password_auth_supported(self) -> bool:\n-        return True\n-\n-    def validate_password(self, username: str, password: str) -> bool:\n-        return True\n-\n-    async def run(self) -> None:\n-        self.write(\"Welcome to chat!\\n\\n\")\n-\n-        self.write(\"Enter your name: \")\n-        name = (await self.readline()).rstrip(\"\\n\")\n-\n-        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n-\n-        self._clients.append(self)\n-        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n-\n-        try:\n-            async for line in self._process.stdin:\n-                self.broadcast(f\"{name}: {line}\")\n-        except asyncssh.BreakReceived:\n-            pass\n-\n-        self.broadcast(f\"*** {name} has left chat ***\\n\")\n-        self._clients.remove(self)\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.listen(\n-        \"\",\n-        2235,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=ChatClient.handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+\t_clients:List['ChatClient']=[]\n+\tdef __init__(A,process):A._process=process\n+\t@classmethod\n+\tasync def handle_client(A,process):await A(process).run()\n+\tasync def readline(A):return cast(str,A._process.stdin.readline())\n+\tdef write(A,msg):A._process.stdout.write(msg)\n+\tdef broadcast(A,msg):\n+\t\tfor B in A._clients:\n+\t\t\tif B!=A:B.write(msg)\n+\tdef begin_auth(A,username):return True\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(A,username,password):return True\n+\tasync def run(A):\n+\t\tA.write('Welcome to chat!\\n\\n');A.write('Enter your name: ');B=(await A.readline()).rstrip('\\n');A.write(f\"\\n{len(A._clients)} other users are connected.\\n\\n\");A._clients.append(A);A.broadcast(f\"*** {B} has entered chat ***\\n\")\n+\t\ttry:\n+\t\t\tasync for C in A._process.stdin:A.broadcast(f\"{B}: {C}\")\n+\t\texcept asyncssh.BreakReceived:pass\n+\t\tA.broadcast(f\"*** {B} has left chat ***\\n\");A._clients.remove(A)\n+async def start_server():await asyncssh.listen('',2235,server_host_keys=['ssh_host_key'],process_factory=ChatClient.handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Initial file manager UI and basic functionality", "commit": "4c34d7eda58530eddb2c8b3479627180d6eeb248", "diff": "commit 4c34d7eda58530eddb2c8b3479627180d6eeb248\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:30:53 2025 +0200\n\n    New stuff.\n\ndiff --git a/src/snek/static/file-manager.css b/src/snek/static/file-manager.css\nnew file mode 100644\nindex 0000000..89b3eec\n--- /dev/null\n+++ b/src/snek/static/file-manager.css\n@@ -0,0 +1,41 @@\n+    .file-manager {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n+      gap: 16px;\n+      padding: 20px;\n+      font-family: Arial, sans-serif;\n+      max-width: 800px;\n+      margin: 0 auto;\n+      border-radius: 8px;\n+    }\n+    .file-tile {\n+      border-radius: 8px;\n+      overflow: hidden;\n+      text-align: center;\n+      padding: 10px;\n+      transition: transform 0.2s;\n+    }\n+    .file-tile:hover {\n+      transform: translateY(-5px);\n+    }\n+    .file-icon {\n+      font-size: 40px;\n+      margin-bottom: 10px;\n+    }\n+    .file-name {\n+      font-size: 14px;\n+      overflow-wrap: break-word;\n+    }\n+    .file-tile img {\n+      max-width: 80%;\n+      height: auto;\n+      margin-bottom: 10px;\n+      border-radius: 4px;\n+    }\n+\n+\ndiff --git a/src/snek/static/file-manager.js b/src/snek/static/file-manager.js\nnew file mode 100644\nindex 0000000..55dbac6\n--- /dev/null\n+++ b/src/snek/static/file-manager.js\n@@ -0,0 +1,100 @@\n+class FileBrowser extends HTMLElement {\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: \"open\" });\n+  }\n+\n+  connectedCallback() {\n+    this.renderShell();\n+    this.load();\n+  }\n+\n+  renderShell() {\n+    this.shadowRoot.innerHTML = `\n+      <style>\n+        :host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }\n+        nav    { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }\n+        .crumb  { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n+        .grid   { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }\n+        .tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }\n+        img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }\n+        .icon { font-size:48px; line-height:90px; }\n+      </style>\n+\n+      <nav>\n+        <button id=\"up\">\u2b05\ufe0f\u00a0Up</button>\n+        <span class=\"crumb\" id=\"crumb\"></span>\n+      </nav>\n+      <div class=\"grid\" id=\"grid\"></div>\n+      <nav>\n+        <button id=\"prev\">Prev</button>\n+        <button id=\"next\">Next</button>\n+      </nav>\n+    `;\n+    this.shadowRoot.getElementById(\"up\").addEventListener(\"click\", () => this.goUp());\n+    this.shadowRoot.getElementById(\"prev\").addEventListener(\"click\", () => {\n+      if (this.offset > 0) { this.offset -= this.limit; this.load(); }\n+    });\n+    this.shadowRoot.getElementById(\"next\").addEventListener(\"click\", () => {\n+      this.offset += this.limit; this.load();\n+    });\n+  }\n+\n+  async load() {\n+    const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`);\n+    if (!r.ok) { console.error(await r.text()); return; }\n+    const data = await r.json();\n+    this.renderTiles(data.items);\n+    this.updateNav(data.pagination);\n+  }\n+\n+  renderTiles(items) {\n+    const grid = this.shadowRoot.getElementById(\"grid\");\n+    grid.innerHTML = \"\";\n+    items.forEach(item => {\n+      const tile = document.createElement(\"div\");\n+      tile.className = \"tile\";\n+\n+      if (item.type === \"directory\") {\n+        tile.innerHTML = `<div class=\"icon\">\ud83d\udcc2</div><div>${item.name}</div>`;\n+        tile.addEventListener(\"click\", () => { this.path = item.path; this.offset = 0; this.load(); });\n+      } else {\n+        if (item.mimetype?.startsWith(\"image/\")) {\n+          tile.innerHTML = `<img class=\"thumb\" src=\"${item.url}\" alt=\"${item.name}\"><div>${item.name}</div>`;\n+        } else {\n+          tile.innerHTML = `<div class=\"icon\">\ud83d\udcc4</div><div>${item.name}</div>`;\n+        }\n+        tile.addEventListener(\"click\", () => window.open(item.url, \"_blank\"));\n+      }\n+\n+      grid.appendChild(tile);\n+    });\n+  }\n+\n+  updateNav({ offset, limit, total }) {\n+    this.shadowRoot.getElementById(\"crumb\").textContent = `/${this.path}`;\n+    this.shadowRoot.getElementById(\"prev\").disabled = offset === 0;\n+    this.shadowRoot.getElementById(\"next\").disabled = offset + limit >= total;\n+    this.shadowRoot.getElementById(\"up\").disabled   = this.path === \"\";\n+  }\n+\n+  goUp() {\n+    if (!this.path) return;\n+    this.path = this.path.split(\"/\").slice(0, -1).join(\"/\");\n+    this.offset = 0;\n+    this.load();\n+  }\n+}\n+\n+customElements.define(\"file-manager\", FileBrowser);\ndiff --git a/src/snek/templates/repository.html b/src/snek/templates/repository.html\nnew file mode 100644\nindex 0000000..0052dc5\n--- /dev/null\n+++ b/src/snek/templates/repository.html\n@@ -0,0 +1,82 @@\n+{% extends \"app.html\" %}\n+{% block header_text %}{{rel_path}}{% endblock %}\n+\n+{% block main %}\n+\n+ <style>\n+\n+    .file-list {\n+      display: flex;\n+      flex-direction: column;\n+      gap: 0.5rem;\n+      overflow-y: auto;\n+    }\n+\n+    .file-item {\n+      display: flex;\n+      align-items: center;\n+      padding: 1rem;\n+      border-radius: 0.5rem;\n+      transition: background 0.2s;\n+    }\n+\n+    .file-item:hover {\n+    }\n+\n+    .file-icon {\n+      flex: 0 0 30px;\n+      text-align: center;\n+      margin-right: 1rem;\n+    }\n+\n+    .file-name {\n+      flex: 1;\n+      font-weight: bold;\n+      white-space: nowrap;\n+      overflow: hidden;\n+      text-overflow: ellipsis;\n+    }\n+\n+    .file-type, .file-size {\n+      flex: 0 0 auto;\n+      margin-left: 1rem;\n+      font-size: 0.9rem;\n+    }\n+\n+    @media (max-width: 600px) {\n+      .file-item {\n+        flex-direction: column;\n+        align-items: flex-start;\n+        gap: 0.25rem;\n+      }\n+\n+      .file-type, .file-size {\n+        margin-left: 0;\n+      }\n+    }\n+  </style>\n+    <div class=\"container\">\n+  <div class=\"file-list\">\n+    {% for file in files %}\n+    <a href=\"/repository/{{username}}/{{repo_name}}/{{ file.path }}\" class=\"file-item\">\n+        <div class=\"file-icon\">\n+          {% if file.type == 'tree' %}\n+            <i class=\"fa fa-folder\"></i>\n+          {% else %}\n+            <i class=\"fa fa-file\"></i>\n+          {% endif %}\n+        </div>\n+        <div class=\"file-name\">{{ file.name }}</div>\n+        <div class=\"file-type\">{{ file.type }}</div>\n+        <div class=\"file-size\">{{ file.size }} B</div>\n+      </a>\n+    {% endfor %}\n+  </div>\n+  </div>\n+\n+  {% endblock %}\n+\ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nnew file mode 100644\nindex 0000000..f7a2e9d\n--- /dev/null\n+++ b/src/snek/view/repository.py\n@@ -0,0 +1,15 @@\n+from snek.system.view import BaseView\n+from aiohttp import web\n+class RepositoryView(BaseView):\n+\tasync def get(A):\n+\t\tG='type';H='name';I='.git';J='username';B=A.request.match_info[J];K=A.request.match_info['repo_name'];C=A.request.match_info.get('rel_path','')\n+\t\tif not B.count('-')==4:E=await A.services.user.get_by_username(B)\n+\t\telse:E=await A.services.user.get(B)\n+\t\tif not E:return web.HTTPNotFound()\n+\t\tB=E[J];M=await A.services.user.get_repository_path(E['uid'])\n+\t\tif C.endswith(I):C=C[:-4]\n+\t\tL=M.joinpath(K+I)\n+\t\tif not L.exists():return web.HTTPNotFound()\n+\t\timport os;from git import Repo;N=Repo(L.joinpath(C));F=[];O=[];P=N.head.commit\n+\t\tfor D in P.tree.traverse():F.append({H:D.name,'mode':D.mode,G:D.type,'path':D.path,'size':D.size})\n+\t\tsorted(F,key=lambda x:x[H]);sorted(F,key=lambda x:x[G],reverse=True);Q=f\"{B}/{C}\"[:-4];return await A.render_template('repository.html',dict(username=B,repo_name=K,rel_path=C,full_path=Q,files=F,directories=O))\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-05-09", "line": "'NoneType' object is not subscriptable", "commit": "1616e4edb97284f705400c0598306202a083f60f", "diff": "commit 1616e4edb97284f705400c0598306202a083f60f\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Fri May 9 14:57:22 2025 +0200\n\n    revert 17c6124a57a394c63427a0038e598fdb40560f15\n    \n    revert Minify.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex e69de29..8b13789 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -0,0 +1 @@\n+\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 5d861d9..35e56e3 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,21 +1,32 @@\n-_D='Database path for the application'\n-_C='snek.db'\n-_B='--db_path'\n-_A=True\n-import click,uvloop\n+import click\n+import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n+\n @click.group()\n-def cli():0\n+def cli():\n+    pass\n+\n @cli.command()\n-@click.option('--port',default=8081,show_default=_A,help='Port to run the application on')\n-@click.option('--host',default='0.0.0.0',show_default=_A,help='Host to run the application on')\n-@click.option(_B,default=_C,show_default=_A,help=_D)\n+@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n+@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def serve(port, host, db_path):\n+    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    web.run_app(\n+    )\n+\n @cli.command()\n-@click.option(_B,default=_C,show_default=_A,help=_D)\n-def main():cli()\n-if __name__=='__main__':main()\n\\ No newline at end of file\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def shell(db_path):\n+    start_ipython(argv=[], user_ns={'app': app})\n+\n+def main():\n+    cli()\n+\n+if __name__ == \"__main__\":\n+    main()\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f5f1948..ceb7c9d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,31 +1,37 @@\n-_G='name'\n-_F='static'\n-_E='user'\n-_D=None\n-_C=True\n-_B='channel_uid'\n-_A='uid'\n-import asyncio,logging,pathlib,time,uuid\n+import asyncio\n+import logging\n+import pathlib\n+import time\n+import uuid\n+\n from snek.view.threads import ThreadsView\n+\n logging.basicConfig(level=logging.DEBUG)\n+\n from concurrent.futures import ThreadPoolExecutor\n+\n from aiohttp import web\n-from aiohttp_session import get_session as session_get,session_middleware,setup as session_setup\n+from aiohttp_session import (\n+    get_session as session_get,\n+    session_middleware,\n+    setup as session_setup,\n+)\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n+\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import auth_middleware,cors_middleware\n+from snek.system.middleware import auth_middleware, cors_middleware\n from snek.system.profiler import profiler_handler\n-from snek.system.template import EmojiExtension,LinkifyExtension,PythonExtension\n-from snek.view.about import AboutHTMLView,AboutMDView\n+from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n+from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.avatar import AvatarView\n-from snek.view.docs import DocsHTMLView,DocsMDView\n+from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n@@ -42,79 +48,275 @@ from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n from snek.view.status import StatusView\n-from snek.view.terminal import TerminalSocketView,TerminalView\n+from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-SESSION_KEY=b'c79a0c5fda4b424189c427d28c9f7c34'\n+\n+SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n+\n+\n @web.middleware\n-async def session_middleware(request,handler):A=request;setattr(A,'session',await session_get(A));B=await handler(A);return B\n+async def session_middleware(request, handler):\n+    setattr(request, \"session\", await session_get(request))\n+    response = await handler(request)\n+    return response\n+\n+\n @web.middleware\n-async def trailing_slash_middleware(request,handler):\n-\tA=request\n-\tif A.path and not A.path.endswith('/'):raise web.HTTPFound(A.path+'/')\n-\treturn await handler(A)\n+async def trailing_slash_middleware(request, handler):\n+    if request.path and not request.path.endswith(\"/\"):\n+        raise web.HTTPFound(request.path + \"/\")\n+    return await handler(request)\n+\n+\n class Application(BaseApplication):\n-\tdef __init__(A,*B,**C):D=[cors_middleware,web.normalize_path_middleware(merge_slashes=_C)];A.template_path=pathlib.Path(__file__).parent.joinpath('templates');A.static_path=pathlib.Path(__file__).parent.joinpath(_F);super().__init__(middlewares=D,template_path=A.template_path,client_max_size=5368709120*B,**C);session_setup(A,EncryptedCookieStorage(SESSION_KEY));A.tasks=asyncio.Queue();A._middlewares.append(session_middleware);A._middlewares.append(auth_middleware);A.jinja2_env.add_extension(MarkdownExtension);A.jinja2_env.add_extension(LinkifyExtension);A.jinja2_env.add_extension(PythonExtension);A.jinja2_env.add_extension(EmojiExtension);A.setup_router();A.executor=_D;A.cache=Cache(A);A.services=get_services(app=A);A.mappers=get_mappers(app=A);A.on_startup.append(A.prepare_asyncio);A.on_startup.append(A.prepare_database)\n-\tasync def prepare_asyncio(A,app):app.executor=ThreadPoolExecutor(max_workers=200);app.loop.set_default_executor(A.executor)\n-\tasync def create_task(A,task):await A.tasks.put(task)\n-\tasync def task_runner(A):\n-\t\twhile _C:\n-\t\t\tB=await A.tasks.get();A.db.begin()\n-\t\t\ttry:C=time.time();await B;D=time.time();print(f\"Task {B} took {D-C} seconds\");A.tasks.task_done()\n-\t\t\texcept Exception as E:print(E)\n-\t\t\tA.db.commit()\n-\tasync def prepare_database(A,app):\n-\t\tC='channel_message';D='channel_member';E='username';B='user_uid';A.db.query('PRAGMA journal_mode=WAL');A.db.query('PRAGMA syncnorm=off')\n-\t\ttry:\n-\t\t\tif not A.db[_E].has_index(E):A.db[_E].create_index(E,unique=_C)\n-\t\t\tif not A.db[D].has_index([_B,B]):A.db[D].create_index([_B,B])\n-\t\t\tif not A.db[C].has_index([_B,B]):A.db[C].create_index([_B,B])\n-\t\texcept:pass\n-\t\tawait app.services.drive.prepare_all();A.loop.create_task(A.task_runner())\n-\tdef setup_router(A):A.router.add_get('/',IndexView);A.router.add_static('/',pathlib.Path(__file__).parent.joinpath(_F),name=_F,show_index=_C);A.router.add_view('/profiler.html',profiler_handler);A.router.add_view('/about.html',AboutHTMLView);A.router.add_view('/about.md',AboutMDView);A.router.add_view('/logout.json',LogoutView);A.router.add_view('/logout.html',LogoutView);A.router.add_view('/docs.html',DocsHTMLView);A.router.add_view('/docs.md',DocsMDView);A.router.add_view('/status.json',StatusView);A.router.add_view('/settings/index.html',SettingsIndexView);A.router.add_view('/settings/profile.html',SettingsProfileView);A.router.add_view('/settings/profile.json',SettingsProfileView);A.router.add_view('/web.html',WebView);A.router.add_view('/login.html',LoginView);A.router.add_view('/login.json',LoginView);A.router.add_view('/register.html',RegisterView);A.router.add_view('/register.json',RegisterView);A.router.add_view('/drive/{rel_path:.*}',DriveView);A.router.add_view('/drive.bin',UploadView);A.router.add_view('/drive.bin/{uid}.{ext}',UploadView);A.router.add_view('/search-user.html',SearchUserView);A.router.add_view('/search-user.json',SearchUserView);A.router.add_view('/avatar/{uid}.svg',AvatarView);A.router.add_get('/http-get',A.handle_http_get);A.router.add_get('/http-photo',A.handle_http_photo);A.router.add_get('/rpc.ws',RPCView);A.router.add_view('/channel/{channel}.html',WebView);A.router.add_view('/threads.html',ThreadsView);A.router.add_view('/terminal.ws',TerminalSocketView);A.router.add_view('/terminal.html',TerminalView);A.router.add_view('/drive.json',DriveView);A.router.add_view('/drive/{drive}.json',DriveView);A.router.add_view('/stats.json',StatsView);A.router.add_view('/user/{user}.html',UserView);A.router.add_view('/repository/{username}/{repo_name}',RepositoryView);A.router.add_view('/repository/{username}/{repo_name}/{rel_path:.*}',RepositoryView);A.router.add_view('/settings/repositories/index.html',RepositoriesIndexView);A.router.add_view('/settings/repositories/create.html',RepositoriesCreateView);A.router.add_view('/settings/repositories/repository/{name}/update.html',RepositoriesUpdateView);A.router.add_view('/settings/repositories/repository/{name}/delete.html',RepositoriesDeleteView);A.webdav=WebdavApplication(A);A.git=GitApplication(A);A.add_subapp('/webdav',A.webdav);A.add_subapp('/git',A.git)\n-\tasync def handle_test(A,request):return await A.render_template('test.html',request,context={_G:'retoor'})\n-\tasync def handle_http_get(C,request):A=request.query.get('url');B=await http.get(A);return web.Response(body=B)\n-\tasync def handle_http_photo(C,request):A=request.query.get('url');B=await http.create_site_photo(A);return web.Response(body=B.read_bytes(),headers={'Content-Type':'image/png'})\n-\tasync def render_template(A,template,request,context=_D):\n-\t\tI='channels';J='new_count';K='color';L=template;F='last_message_on';D=request;C=context;G=[]\n-\t\tif not C:C={}\n-\t\tC['rid']=str(uuid.uuid4())\n-\t\tif D.session.get(_A):\n-\t\t\tasync for E in A.services.channel_member.find(user_uid=D.session.get(_A),deleted_at=_D,is_banned=False):\n-\t\t\t\tB={};M=await A.services.channel_member.get_other_dm_user(E[_B],D.session.get(_A));H=await E.get_channel();N=await H.get_last_message();O=_D\n-\t\t\t\tif N:P=await N.get_user();O=P[K]\n-\t\t\t\tB[K]=O;B[F]=H[F];B['is_private']=H['tag']=='dm'\n-\t\t\t\tif M:B[_G]=M['nick'];B[_A]=E[_B]\n-\t\t\t\telse:B[_G]=E['label'];B[_A]=E[_B]\n-\t\t\t\tB[J]=E[J];G.append(B)\n-\t\t\tG.sort(key=lambda x:x[F]or'',reverse=_C)\n-\t\t\tif I not in C:C[I]=G\n-\t\t\tif _E not in C:C[_E]=await A.services.user.get(D.session.get(_A))\n-\t\tA.template_path.joinpath(L);await A.services.user.get_template_path(D.session.get(_A));A.original_loader=A.jinja2_env.loader;A.jinja2_env.loader=await A.get_user_template_loader(D.session.get(_A));Q=await super().render_template(L,D,C);A.jinja2_env.loader=A.original_loader;return Q\n-\tasync def static_handler(B,request):\n-\t\tD=request;E=D.match_info.get('filename','');C=[];F=D.session.get(_A)\n-\t\tif F:\n-\t\t\tA=await B.services.user.get_static_path(F)\n-\t\t\tif A:C.append(A)\n-\t\tfor H in B.services.user.get_admin_uids():\n-\t\t\tA=await B.services.user.get_static_path(H)\n-\t\t\tif A:C.append(A)\n-\t\tC.append(B.static_path)\n-\t\tfor G in C:\n-\t\t\tif pathlib.Path(G).joinpath(E).exists():return web.FileResponse(pathlib.Path(G).joinpath(E))\n-\t\treturn web.HTTPNotFound()\n-\tasync def get_user_template_loader(B,uid=_D):\n-\t\tC=[]\n-\t\tfor D in B.services.user.get_admin_uids():\n-\t\t\tA=await B.services.user.get_template_path(D)\n-\t\t\tif A:C.append(A)\n-\t\tif uid:\n-\t\t\tA=await B.services.user.get_template_path(uid)\n-\t\t\tif A:C.append(A)\n-\t\tC.append(B.template_path);return FileSystemLoader(C)\n-async def main():await web._run_app(app,port=8081,host='0.0.0.0')\n-if __name__=='__main__':asyncio.run(main())\n\\ No newline at end of file\n+\n+    def __init__(self, *args, **kwargs):\n+        middlewares = [\n+            cors_middleware,\n+            web.normalize_path_middleware(merge_slashes=True),\n+        ]\n+        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n+        super().__init__(\n+            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n+        )\n+        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n+        self.tasks = asyncio.Queue()\n+        self._middlewares.append(session_middleware)\n+        self._middlewares.append(auth_middleware)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+        self.jinja2_env.add_extension(LinkifyExtension)\n+        self.jinja2_env.add_extension(PythonExtension)\n+        self.jinja2_env.add_extension(EmojiExtension)\n+\n+        self.setup_router()\n+        self.executor = None\n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_asyncio)\n+        self.on_startup.append(self.prepare_database)\n+\n+    async def prepare_asyncio(self, app):\n+        app.executor = ThreadPoolExecutor(max_workers=200)\n+        app.loop.set_default_executor(self.executor)\n+\n+    async def create_task(self, task):\n+        await self.tasks.put(task)\n+\n+    async def task_runner(self):\n+        while True:\n+            task = await self.tasks.get()\n+            self.db.begin()\n+            try:\n+                task_start = time.time()\n+                await task\n+                task_end = time.time()\n+                print(f\"Task {task} took {task_end - task_start} seconds\")\n+                self.tasks.task_done()\n+            except Exception as ex:\n+                print(ex)\n+            self.db.commit()\n+\n+    async def prepare_database(self, app):\n+        self.db.query(\"PRAGMA journal_mode=WAL\")\n+        self.db.query(\"PRAGMA syncnorm=off\")\n+\n+        try:\n+            if not self.db[\"user\"].has_index(\"username\"):\n+                self.db[\"user\"].create_index(\"username\", unique=True)\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n+        except:\n+            pass\n+\n+        await app.services.drive.prepare_all()\n+        self.loop.create_task(self.task_runner())\n+\n+    def setup_router(self):\n+        self.router.add_get(\"/\", IndexView)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n+        self.router.add_view(\"/profiler.html\", profiler_handler)\n+        self.router.add_view(\"/about.html\", AboutHTMLView)\n+        self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/logout.json\", LogoutView)\n+        self.router.add_view(\"/logout.html\", LogoutView)\n+        self.router.add_view(\"/docs.html\", DocsHTMLView)\n+        self.router.add_view(\"/docs.md\", DocsMDView)\n+        self.router.add_view(\"/status.json\", StatusView)\n+        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n+        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n+        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n+        self.router.add_view(\"/web.html\", WebView)\n+        self.router.add_view(\"/login.html\", LoginView)\n+        self.router.add_view(\"/login.json\", LoginView)\n+        self.router.add_view(\"/register.html\", RegisterView)\n+        self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n+        self.router.add_view(\"/drive.bin\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n+        self.router.add_view(\"/search-user.html\", SearchUserView)\n+        self.router.add_view(\"/search-user.json\", SearchUserView)\n+        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n+        self.router.add_get(\"/http-get\", self.handle_http_get)\n+        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n+        self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel}.html\", WebView)\n+        self.router.add_view(\"/threads.html\", ThreadsView)\n+        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n+        self.router.add_view(\"/terminal.html\", TerminalView)\n+        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.router.add_view(\"/stats.json\", StatsView)\n+        self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n+        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n+        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n+        self.webdav = WebdavApplication(self)\n+        self.git = GitApplication(self)\n+        self.add_subapp(\"/webdav\", self.webdav)\n+        self.add_subapp(\"/git\",self.git)\n+        \n+    \n+    async def handle_test(self, request):\n+\n+        return await self.render_template(\n+            \"test.html\", request, context={\"name\": \"retoor\"}\n+        )\n+\n+    async def handle_http_get(self, request: web.Request):\n+        url = request.query.get(\"url\")\n+        content = await http.get(url)\n+        return web.Response(body=content)\n+\n+    async def handle_http_photo(self, request):\n+        url = request.query.get(\"url\")\n+        path = await http.create_site_photo(url)\n+        return web.Response(\n+            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n+        )\n+\n+    async def render_template(self, template, request, context=None):\n+        channels = []\n+        if not context:\n+            context = {}\n+        context[\"rid\"] = str(uuid.uuid4())\n+        if request.session.get(\"uid\"):\n+            async for subscribed_channel in self.services.channel_member.find(\n+                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n+                item = {}\n+                other_user = await self.services.channel_member.get_other_dm_user(\n+                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n+                )\n+                parent_object = await subscribed_channel.get_channel()\n+                last_message = await parent_object.get_last_message()\n+                color = None\n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                item[\"color\"] = color\n+                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n+                if other_user:\n+                    item[\"name\"] = other_user[\"nick\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                else:\n+                    item[\"name\"] = subscribed_channel[\"label\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n+\n+                channels.append(item)\n+\n+            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+            if \"channels\" not in context:\n+                context[\"channels\"] = channels\n+            if \"user\" not in context:\n+                context[\"user\"] = await self.services.user.get(\n+                    request.session.get(\"uid\")\n+                )\n+\n+        self.template_path.joinpath(template)\n+\n+        await self.services.user.get_template_path(request.session.get(\"uid\"))\n+\n+        self.original_loader = self.jinja2_env.loader\n+\n+        self.jinja2_env.loader = await self.get_user_template_loader(\n+            request.session.get(\"uid\")\n+        )\n+\n+        rendered = await super().render_template(template, request, context)\n+\n+        self.jinja2_env.loader = self.original_loader\n+\n+        return rendered\n+\n+\n+    async def static_handler(self, request):\n+        file_name = request.match_info.get('filename', '')\n+\n+        paths = []\n+\n+        uid = request.session.get(\"uid\")\n+        if uid:\n+            user_static_path = await self.services.user.get_static_path(uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+      \n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_static_path = await self.services.user.get_static_path(admin_uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+        \n+        paths.append(self.static_path)\n+\n+        for path in paths:\n+            if pathlib.Path(path).joinpath(file_name).exists():\n+                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n+        return web.HTTPNotFound()\n+\n+    async def get_user_template_loader(self, uid=None):\n+        template_paths = []\n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_template_path = await self.services.user.get_template_path(admin_uid)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n+        if uid:\n+            user_template_path = await self.services.user.get_template_path(uid)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n+\n+        template_paths.append(self.template_path)\n+        return FileSystemLoader(template_paths)\n+\n+\n+\n+\n+async def main():\n+    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n+\n+\n+if __name__ == \"__main__\":\n+    asyncio.run(main())\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex b47df44..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,14 +1,43 @@\n import pathlib\n+\n from aiohttp import web\n from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n+\n+\n class Application(BaseApplication):\n-\tdef __init__(A,path=None,*B,**C):A.path=pathlib.Path(path);D=A.path;super().__init__(*B,template_path=D,**C);A.jinja2_env.add_extension(MarkdownExtension);A.router.add_get('/{tail:.*}',A.handle_document)\n-\tasync def handle_document(B,request):\n-\t\tD='text/plain';E=b'Resource is not found on this server.';F='index.html';G=request;C=G.match_info['tail'].strip('/')\n-\t\tif C=='':C=F\n-\t\tA=B.path.joinpath(C)\n-\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n-\t\tif A.is_dir():A=A.joinpath(F)\n-\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n-\t\tH=await B.render_template(str(A.relative_to(B.path)),G);return H\n\\ No newline at end of file\n+\n+    def __init__(self, path=None, *args, **kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+\n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 2d52196..b254756 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,12 +1,39 @@\n-_B='created_at'\n-_A='uid'\n import asyncio\n+\n from snek.app import app\n-async def fix_message(message):C='user';D='text';B='user_uid';A=message;A={_A:A[_A],B:A[B],D:A['message'],'sent':A[_B]};E=await app.services.user.get(uid=A[B]);A[C]=E and E['username']or None;return(A[C]or'')+': '+(A[D]or'')\n+\n+\n+async def fix_message(message):\n+    message = {\n+        \"uid\": message[\"uid\"],\n+        \"user_uid\": message[\"user_uid\"],\n+        \"text\": message[\"message\"],\n+        \"sent\": message[\"created_at\"],\n+    }\n+    user = await app.services.user.get(uid=message[\"user_uid\"])\n+    message[\"user\"] = user and user[\"username\"] or None\n+    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n+\n+\n async def dump_public_channels():\n-\tA=[]\n-\tfor B in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):print(f\"Dumping channel: {B[\"label\"]}.\");A+=[await fix_message(A)for A in app.db['channel_message'].find(channel_uid=B[_A],order_by=_B)];print('Dump succesfull!')\n-\tprint('Converting to json.');print('Converting succesful, now writing to dump.json')\n-\twith open('dump.txt','w')as C:C.write('\\n\\n'.join(A))\n-\tprint('Dump written to dump.json')\n-if __name__=='__main__':asyncio.run(dump_public_channels())\n\\ No newline at end of file\n+    result = []\n+    for channel in app.db[\"channel\"].find(\n+        is_private=False, is_listed=True, tag=\"public\"\n+    ):\n+        print(f\"Dumping channel: {channel['label']}.\")\n+        result += [\n+            await fix_message(record)\n+            for record in app.db[\"channel_message\"].find(\n+                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n+            )\n+        ]\n+        print(\"Dump succesfull!\")\n+    print(\"Converting to json.\")\n+    print(\"Converting succesful, now writing to dump.json\")\n+    with open(\"dump.txt\", \"w\") as f:\n+        f.write(\"\\n\\n\".join(result))\n+    print(\"Dump written to dump.json\")\n+\n+\n+if __name__ == \"__main__\":\n+    asyncio.run(dump_public_channels())\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex c0b8cfd..ef13d67 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,14 +1,51 @@\n-_B='username'\n-_A='password'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n class AuthField(FormInputElement):\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=await super().errors\n-\t\tif A.model.password.value and A.model.username.value:\n-\t\t\tif not await A.app.services.user.validate_login(A.model.username.value,A.model.password.value):return['Invalid username or password']\n-\t\treturn B\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.model.password.value and self.model.username.value:\n+            if not await self.app.services.user.validate_login(\n+                self.model.username.value, self.model.password.value\n+            ):\n+                return [\"Invalid username or password\"]\n+        return result\n+\n+\n class LoginForm(Form):\n-\ttitle=HTMLElement(tag='h1',text='Login');username=AuthField(name=_B,required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');password=AuthField(name=_A,required=True,min_length=1,type=_A,place_holder='Password');action=FormButtonElement(name='action',value='submit',text='Login',type='button')\n-\t@property\n-\tasync def is_valid(self):A=self;return all([A[_B],A[_A],not await A.username.errors,not await A.password.errors])\n\\ No newline at end of file\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Login\")\n+\n+    username = AuthField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\",\n+    )\n+    password = AuthField(\n+        name=\"password\",\n+        required=True,\n+        min_length=1,\n+        type=\"password\",\n+        place_holder=\"Password\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n+    )\n+\n+    @property\n+    async def is_valid(self):\n+        return all(\n+            [\n+                self[\"username\"],\n+                self[\"password\"],\n+                not await self.username.errors,\n+                not await self.password.errors,\n+            ]\n+        )\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex a9f8c71..b105696 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,10 +1,44 @@\n-_B='password'\n-_A='Register'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n class UsernameField(FormInputElement):\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=await super().errors\n-\t\tif A.value and await A.app.services.user.count(username=A.value):B.append('Username is not available.')\n-\t\treturn B\n-class RegisterForm(Form):title=HTMLElement(tag='h1',text=_A);username=UsernameField(name='username',required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');email=FormInputElement(name='email',required=False,regex='^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$',place_holder='Email address',type='email');password=FormInputElement(name=_B,required=True,min_length=1,type=_B,place_holder='Password');action=FormButtonElement(name='action',value='submit',text=_A,type='button')\n\\ No newline at end of file\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\",\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        min_length=1,\n+        type=\"password\",\n+        place_holder=\"Password\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n+    )\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex bf6de66..7e946b9 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -1,2 +1,18 @@\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n-class SearchUserForm(Form):title=HTMLElement(tag='h1',text='Search user');username=FormInputElement(name='username',required=True,min_length=1,max_length=128,place_holder='Username');action=FormButtonElement(name='action',value='submit',text='Search',type='button')\n\\ No newline at end of file\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SearchUserForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n+\n+    username = FormInputElement(\n+        name=\"username\",\n+        required=True,\n+        min_length=1,\n+        max_length=128,\n+        place_holder=\"Username\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n+    )\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 094c28e..836cd67 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,5 +1,25 @@\n-_C='button'\n-_B='submit'\n-_A='action'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n-class SettingsProfileForm(Form):nick=FormInputElement(name='nick',required=True,place_holder='Your Nickname',min_length=1,max_length=20);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C);title=HTMLElement(tag='h1',text='Profile');profile=FormInputElement(name='profile',place_holder='Tell about yourself.',required=False,max_length=300);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C)\n\\ No newline at end of file\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SettingsProfileForm(Form):\n+\n+    nick = FormInputElement(\n+        name=\"nick\",\n+        required=True,\n+        place_holder=\"Your Nickname\",\n+        min_length=1,\n+        max_length=20,\n+    )\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n+    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n+    profile = FormInputElement(\n+        name=\"profile\",\n+        place_holder=\"Tell about yourself.\",\n+        required=False,\n+        max_length=300,\n+    )\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex b51f326..8583142 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,2 +1,3 @@\n from snek.app import app\n-application=app\n\\ No newline at end of file\n+\n+application = app\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 917ab7d..ab7904f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,4 +1,5 @@\n import functools\n+\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n@@ -9,6 +10,24 @@ from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_mappers(app=None):A=app;return Object(**{'user':UserMapper(app=A),'channel_member':ChannelMemberMapper(app=A),'channel':ChannelMapper(app=A),'channel_message':ChannelMessageMapper(app=A),'notification':NotificationMapper(app=A),'drive_item':DriveItemMapper(app=A),'drive':DriveMapper(app=A),'user_property':UserPropertyMapper(app=A),'repository':RepositoryMapper(app=A)})\n-def get_mapper(name,app=None):return get_mappers(app=app)[name]\n\\ No newline at end of file\n+def get_mappers(app=None):\n+    return Object(\n+        **{\n+            \"user\": UserMapper(app=app),\n+            \"channel_member\": ChannelMemberMapper(app=app),\n+            \"channel\": ChannelMapper(app=app),\n+            \"channel_message\": ChannelMessageMapper(app=app),\n+            \"notification\": NotificationMapper(app=app),\n+            \"drive_item\": DriveItemMapper(app=app),\n+            \"drive\": DriveMapper(app=app),\n+            \"user_property\": UserPropertyMapper(app=app),\n+            \"repository\": RepositoryMapper(app=app),\n+        }\n+    )\n+\n+\n+def get_mapper(name, app=None):\n+    return get_mappers(app=app)[name]\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nindex d663d5b..6239dc8 100644\n--- a/src/snek/mapper/channel.py\n+++ b/src/snek/mapper/channel.py\n@@ -1,3 +1,7 @@\n from snek.model.channel import ChannelModel\n from snek.system.mapper import BaseMapper\n-class ChannelMapper(BaseMapper):table_name='channel';model_class=ChannelModel\n\\ No newline at end of file\n+\n+\n+class ChannelMapper(BaseMapper):\n+    table_name = \"channel\"\n+    model_class = ChannelModel\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nindex b221d99..f0f62d6 100644\n--- a/src/snek/mapper/channel_member.py\n+++ b/src/snek/mapper/channel_member.py\n@@ -1,3 +1,7 @@\n from snek.model.channel_member import ChannelMemberModel\n from snek.system.mapper import BaseMapper\n-class ChannelMemberMapper(BaseMapper):table_name='channel_member';model_class=ChannelMemberModel\n\\ No newline at end of file\n+\n+\n+class ChannelMemberMapper(BaseMapper):\n+    table_name = \"channel_member\"\n+    model_class = ChannelMemberModel\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex c27a9cb..35ccbe9 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -1,3 +1,7 @@\n from snek.model.channel_message import ChannelMessageModel\n from snek.system.mapper import BaseMapper\n-class ChannelMessageMapper(BaseMapper):model_class=ChannelMessageModel;table_name='channel_message'\n\\ No newline at end of file\n+\n+\n+class ChannelMessageMapper(BaseMapper):\n+    model_class = ChannelMessageModel\n+    table_name = \"channel_message\"\ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex cac318a..c92c687 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -1,3 +1,7 @@\n from snek.model.drive import DriveModel\n from snek.system.mapper import BaseMapper\n-class DriveMapper(BaseMapper):table_name='drive';model_class=DriveModel\n\\ No newline at end of file\n+\n+\n+class DriveMapper(BaseMapper):\n+    table_name = \"drive\"\n+    model_class = DriveModel\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex 96676f6..3d17a61 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,3 +1,8 @@\n from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-class DriveItemMapper(BaseMapper):model_class=DriveItemModel;table_name='drive_item'\n\\ No newline at end of file\n+\n+\n+class DriveItemMapper(BaseMapper):\n+\n+    model_class = DriveItemModel\n+    table_name = \"drive_item\"\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex c2372ce..9bd74b5 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,3 +1,7 @@\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n-class NotificationMapper(BaseMapper):table_name='notification';model_class=NotificationModel\n\\ No newline at end of file\n+\n+\n+class NotificationMapper(BaseMapper):\n+    table_name = \"notification\"\n+    model_class = NotificationModel\ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nindex 1c04ba3..1ac10d4 100644\n--- a/src/snek/mapper/repository.py\n+++ b/src/snek/mapper/repository.py\n@@ -1,3 +1,7 @@\n from snek.model.repository import RepositoryModel\n from snek.system.mapper import BaseMapper\n-class RepositoryMapper(BaseMapper):model_class=RepositoryModel;table_name='repository'\n\\ No newline at end of file\n+\n+\n+class RepositoryMapper(BaseMapper):\n+    model_class = RepositoryModel\n+    table_name = \"repository\"\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 1df0eea..e0df494 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,7 +1,20 @@\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n+\n+\n class UserMapper(BaseMapper):\n-\ttable_name='user';model_class=UserModel\n-\tdef get_admin_uids(A):\n-\t\ttry:return[A['uid']for A in A.db.query('SELECT uid FROM user WHERE is_admin = :is_admin',{'is_admin':True})]\n-\t\texcept Exception as B:print(B);return[]\n\\ No newline at end of file\n+    table_name = \"user\"\n+    model_class = UserModel\n+\n+    def get_admin_uids(self):\n+        try:\n+            return [\n+                user[\"uid\"]\n+                for user in self.db.query(\n+                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n+                    {\"is_admin\": True},\n+                )\n+            ]\n+        except Exception as ex:\n+            print(ex)\n+            return []\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nindex 654e769..7359f60 100644\n--- a/src/snek/mapper/user_property.py\n+++ b/src/snek/mapper/user_property.py\n@@ -1,3 +1,7 @@\n from snek.model.user_property import UserPropertyModel\n from snek.system.mapper import BaseMapper\n-class UserPropertyMapper(BaseMapper):table_name='user_property';model_class=UserPropertyModel\n\\ No newline at end of file\n+\n+\n+class UserPropertyMapper(BaseMapper):\n+    table_name = \"user_property\"\n+    model_class = UserPropertyModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex bb7fb2a..6399c89 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,6 +1,9 @@\n import functools\n+\n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n+\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.drive import DriveModel\n from snek.model.drive_item import DriveItemModel\n@@ -9,6 +12,24 @@ from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_models():return Object(**{'user':UserModel,'channel_member':ChannelMemberModel,'channel':ChannelModel,'channel_message':ChannelMessageModel,'drive_item':DriveItemModel,'drive':DriveModel,'notification':NotificationModel,'user_property':UserPropertyModel,'repository':RepositoryModel})\n-def get_model(name):return get_models()[name]\n\\ No newline at end of file\n+def get_models():\n+    return Object(\n+        **{\n+            \"user\": UserModel,\n+            \"channel_member\": ChannelMemberModel,\n+            \"channel\": ChannelModel,\n+            \"channel_message\": ChannelMessageModel,\n+            \"drive_item\": DriveItemModel,\n+            \"drive\": DriveModel,\n+            \"notification\": NotificationModel,\n+            \"user_property\": UserPropertyModel,\n+            \"repository\": RepositoryModel,\n+        }\n+    )\n+\n+\n+def get_model(name):\n+    return get_models()[name]\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 939d658..0a90c39 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,12 +1,30 @@\n-_C='uid'\n-_B=False\n-_A=True\n from snek.model.channel_message import ChannelMessageModel\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelModel(BaseModel):\n-\tlabel=ModelField(name='label',required=_A,kind=str);description=ModelField(name='description',required=_B,kind=str);tag=ModelField(name='tag',required=_B,kind=str);created_by_uid=ModelField(name='created_by_uid',required=_A,kind=str);is_private=ModelField(name='is_private',required=_A,kind=bool,value=_B);is_listed=ModelField(name='is_listed',required=_A,kind=bool,value=_A);index=ModelField(name='index',required=_A,kind=int,value=1000);last_message_on=ModelField(name='last_message_on',required=_B,kind=str)\n-\tasync def get_last_message(A):\n-\t\ttry:\n-\t\t\tasync for B in A.app.services.channel_message.query('SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1',{'channel_uid':A[_C]}):return await A.app.services.channel_message.get(uid=B[_C])\n-\t\texcept:pass\n-\tasync def get_members(A):return await A.app.services.channel_member.find(channel_uid=A[_C],deleted_at=None,is_banned=_B)\n\\ No newline at end of file\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    description = ModelField(name=\"description\", required=False, kind=str)\n+    tag = ModelField(name=\"tag\", required=False, kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n+    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n+    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n+\n+    async def get_last_message(self) -> ChannelMessageModel:\n+        try:\n+            async for model in self.app.services.channel_message.query(\n+                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+                {\"channel_uid\": self[\"uid\"]},\n+            ):\n+\n+                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+        except:\n+            pass\n+        return None\n+\n+    async def get_members(self):\n+        return await self.app.services.channel_member.find(\n+            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n+        )\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 09b7e91..54b0418 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,19 +1,41 @@\n-_D='channel_uid'\n-_C='user_uid'\n-_B=False\n-_A=True\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelMemberModel(BaseModel):\n-\tlabel=ModelField(name='label',required=_A,kind=str);channel_uid=ModelField(name=_D,required=_A,kind=str);user_uid=ModelField(name=_C,required=_A,kind=str);is_moderator=ModelField(name='is_moderator',required=_A,kind=bool,value=_B);is_read_only=ModelField(name='is_read_only',required=_A,kind=bool,value=_B);is_muted=ModelField(name='is_muted',required=_A,kind=bool,value=_B);is_banned=ModelField(name='is_banned',required=_A,kind=bool,value=_B);new_count=ModelField(name='new_count',required=_B,kind=int,value=0)\n-\tasync def get_user(A):return await A.app.services.user.get(uid=A[_C])\n-\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_D])\n-\tasync def get_name(A):\n-\t\tB=await A.get_channel()\n-\t\tif B['tag']=='dm':C=await A.get_other_dm_user();return C['nick']\n-\t\treturn B['name']or A['label']\n-\tasync def get_other_dm_user(A):\n-\t\tB='uid';C=await A.get_channel()\n-\t\tif C['tag']!='dm':return\n-\t\tasync for D in A.app.services.channel_member.find(channel_uid=C[B]):\n-\t\t\tif D[B]!=A[B]:return await A.app.services.user.get(uid=D[_C])\n-\t\treturn await A.get_user()\n\\ No newline at end of file\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    is_moderator = ModelField(\n+        name=\"is_moderator\", required=True, kind=bool, value=False\n+    )\n+    is_read_only = ModelField(\n+        name=\"is_read_only\", required=True, kind=bool, value=False\n+    )\n+    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n+    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n+\n+    async def get_user(self):\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n+\n+    async def get_name(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] == \"dm\":\n+            user = await self.get_other_dm_user()\n+            return user[\"nick\"]\n+        return channel[\"name\"] or self[\"label\"]\n+\n+    async def get_other_dm_user(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+\n+        async for model in self.app.services.channel_member.find(\n+            channel_uid=channel[\"uid\"]\n+        ):\n+            if model[\"uid\"] != self[\"uid\"]:\n+                return await self.app.services.user.get(uid=model[\"user_uid\"])\n+        return await self.get_user()\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0677d7c..524a8a4 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,8 +1,15 @@\n-_B='user_uid'\n-_A='channel_uid'\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelMessageModel(BaseModel):\n-\tchannel_uid=ModelField(name=_A,required=True,kind=str);user_uid=ModelField(name=_B,required=True,kind=str);message=ModelField(name='message',required=True,kind=str);html=ModelField(name='html',required=False,kind=str)\n-\tasync def get_user(A):return await A.app.services.user.get(uid=A[_B])\n-\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_A])\n\\ No newline at end of file\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    message = ModelField(name=\"message\", required=True, kind=str)\n+    html = ModelField(name=\"html\", required=False, kind=str)\n+\n+    async def get_user(self) -> UserModel:\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex 62a2846..df17d0f 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,6 +1,14 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveModel(BaseModel):\n-\tuser_uid=ModelField(name='user_uid',required=True);name=ModelField(name='name',required=False,type=str)\n-\t@property\n-\tasync def items(self):\n-\t\tasync for A in self.app.services.drive_item.find(drive_uid=self['uid']):yield A\n\\ No newline at end of file\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    name = ModelField(name=\"name\", required=False, type=str)\n+\n+    @property\n+    async def items(self):\n+        async for drive_item in self.app.services.drive_item.find(\n+            drive_uid=self[\"uid\"]\n+        ):\n+            yield drive_item\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex a4427f3..e2b55b4 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,10 +1,21 @@\n-_B='name'\n-_A=True\n import mimetypes\n-from snek.system.model import BaseModel,ModelField\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveItemModel(BaseModel):\n-\tdrive_uid=ModelField(name='drive_uid',required=_A,kind=str);name=ModelField(name=_B,required=_A,kind=str);path=ModelField(name='path',required=_A,kind=str);file_type=ModelField(name='file_type',required=_A,kind=str);file_size=ModelField(name='file_size',required=_A,kind=int);is_available=ModelField(name='is_available',required=_A,kind=bool,initial_value=_A)\n-\t@property\n-\tdef extension(self):return self[_B].split('.')[-1]\n-\t@property\n-\tdef mime_type(self):A,B=mimetypes.guess_type(self[_B]);return A\n\\ No newline at end of file\n+    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n+    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n+\n+    @property\n+    def extension(self):\n+        return self[\"name\"].split(\".\")[-1]\n+\n+    @property\n+    def mime_type(self):\n+        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n+        return mimetype\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex a8453eb..6a12328 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,3 +1,9 @@\n-_A=True\n-from snek.system.model import BaseModel,ModelField\n-class NotificationModel(BaseModel):object_uid=ModelField(name='object_uid',required=_A);object_type=ModelField(name='object_type',required=_A);message=ModelField(name='message',required=_A);user_uid=ModelField(name='user_uid',required=_A);read_at=ModelField(name='is_read',required=_A)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class NotificationModel(BaseModel):\n+    object_uid = ModelField(name=\"object_uid\", required=True)\n+    object_type = ModelField(name=\"object_type\", required=True)\n+    message = ModelField(name=\"message\", required=True)\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    read_at = ModelField(name=\"is_read\", required=True)\ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nindex 40ef94a..598cbb2 100644\n--- a/src/snek/model/repository.py\n+++ b/src/snek/model/repository.py\n@@ -1,3 +1,14 @@\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel,ModelField\n-class RepositoryModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);is_private=ModelField(name='is_private',required=False,kind=bool)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class RepositoryModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    \n+    name = ModelField(name=\"name\", required=True, kind=str)\n+\n+    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n+\n+\n+    \ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 8572402..9869456 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,17 +1,60 @@\n-_D='^[a-zA-Z0-9_-+/]+$'\n-_C=False\n-_B=True\n-_A='uid'\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class UserModel(BaseModel):\n-\tasync def get_property(A,name):\n-\t\tB=await A.app.services.user_property.find_one(user_uid=A[_A],name=name)\n-\t\tif B:return B['value']\n-\tasync def has_property(A,name):return await A.app.services.user_property.exists(user_uid=A[_A],name=name)\n-\tasync def set_property(A,name,value):\n-\t\tC=value;B=name\n-\t\tif not await A.has_property(B):await A.app.services.user_property.insert(user_uid=A[_A],name=B,value=C)\n-\t\telse:await A.app.services.user_property.update(user_uid=A[_A],name=B,value=C)\n-\tasync def get_channel_members(A):\n-\t\tasync for B in A.app.services.channel_member.find(user_uid=A[_A],is_banned=_C,deleted_at=None):yield B\n\\ No newline at end of file\n+\n+    username = ModelField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n+    )\n+    nick = ModelField(\n+        name=\"nick\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n+    )\n+    color = ModelField(\n+    )\n+    email = ModelField(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+    )\n+    password = ModelField(name=\"password\", required=True, min_length=1)\n+\n+    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n+\n+    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n+\n+    async def get_property(self, name):\n+        prop = await self.app.services.user_property.find_one(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+        if prop:\n+            return prop[\"value\"]\n+\n+    async def has_property(self, name):\n+        return await self.app.services.user_property.exists(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+\n+    async def set_property(self, name, value):\n+        if not await self.has_property(name):\n+            await self.app.services.user_property.insert(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+        else:\n+            await self.app.services.user_property.update(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+\n+    async def get_channel_members(self):\n+        async for channel_member in self.app.services.channel_member.find(\n+            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n+        ):\n+            yield channel_member\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 77e5b25..1231423 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,2 +1,7 @@\n-from snek.system.model import BaseModel,ModelField\n-class UserPropertyModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);value=ModelField(name='path',required=True,kind=str)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class UserPropertyModel(BaseModel):\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    value = ModelField(name=\"path\", required=True, kind=str)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 583ef6c..be356dc 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,4 +1,5 @@\n import functools\n+\n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n@@ -12,6 +13,27 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_services(app):A=app;return Object(**{'user':UserService(app=A),'channel_member':ChannelMemberService(app=A),'channel':ChannelService(app=A),'channel_message':ChannelMessageService(app=A),'chat':ChatService(app=A),'socket':SocketService(app=A),'notification':NotificationService(app=A),'util':UtilService(app=A),'drive':DriveService(app=A),'drive_item':DriveItemService(app=A),'user_property':UserPropertyService(app=A),'repository':RepositoryService(app=A)})\n-def get_service(name,app=None):return get_services(app=app)[name]\n\\ No newline at end of file\n+def get_services(app):\n+    return Object(\n+        **{\n+            \"user\": UserService(app=app),\n+            \"channel_member\": ChannelMemberService(app=app),\n+            \"channel\": ChannelService(app=app),\n+            \"channel_message\": ChannelMessageService(app=app),\n+            \"chat\": ChatService(app=app),\n+            \"socket\": SocketService(app=app),\n+            \"notification\": NotificationService(app=app),\n+            \"util\": UtilService(app=app),\n+            \"drive\": DriveService(app=app),\n+            \"drive_item\": DriveItemService(app=app),\n+            \"user_property\": UserPropertyService(app=app),\n+            \"repository\": RepositoryService(app=app),\n+        }\n+    )\n+\n+\n+def get_service(name, app=None):\n+    return get_services(app=app)[name]\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 8c39f6e..b90e66f 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,49 +1,108 @@\n-_F='channel_uid'\n-_E='public'\n-_D=True\n-_C='uid'\n-_B=None\n-_A=False\n from datetime import datetime\n+\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class ChannelService(BaseService):\n-\tmapper_name='channel'\n-\tasync def get(E,uid=_B,**A):\n-\t\tD='name';C=uid\n-\t\tif C:\n-\t\t\tA[_C]=C;B=await super().get(**A)\n-\t\t\tif B:return B\n-\t\t\tdel A[_C];A[D]=C;B=await super().get(**A)\n-\t\t\tif B:return B\n-\t\t\tif B:return B\n-\t\t\treturn\n-\t\treturn await super().get(**A)\n-\tasync def create(C,label,created_by_uid,description=_B,tag=_B,is_private=_A,is_listed=_D):\n-\t\tE=is_listed;D=tag;B=label\n-\t\tF=await C.count(deleted_at=_B)\n-\t\tif not D and not F:D=_E\n-\t\tA=await C.new();A['label']=B;A['description']=description;A['tag']=D;A['created_by_uid']=created_by_uid;A['is_private']=is_private;A['is_listed']=E\n-\t\tif await C.save(A):return A\n-\t\traise Exception(f\"Failed to create channel: {A.errors}.\")\n-\tasync def get_dm(A,user1,user2):\n-\t\tC=user2;B=user1;D=await A.services.channel_member.get_dm(B,C)\n-\t\tif D:return await A.get(uid=D[_F])\n-\t\tE=await A.create('DM',B,tag='dm');await A.services.channel_member.create_dm(E[_C],B,C);return E\n-\tasync def get_users(A,channel_uid):\n-\t\tasync for C in A.services.channel_member.find(channel_uid=channel_uid,is_banned=_A,is_muted=_A,deleted_at=_B):\n-\t\t\tB=await A.services.user.get(uid=C['user_uid'])\n-\t\t\tif B:yield B\n-\tasync def get_online_users(C,channel_uid):\n-\t\tB='last_ping'\n-\t\tasync for A in C.get_users(channel_uid):\n-\t\t\tif not A[B]:continue\n-\t\t\tif(datetime.fromisoformat(now())-datetime.fromisoformat(A[B])).total_seconds()<20:yield A\n-\tasync def get_for_user(A,user_uid):\n-\t\tasync for B in A.services.channel_member.find(user_uid=user_uid,is_banned=_A,deleted_at=_B):C=await A.get(uid=B[_F]);yield C\n-\tasync def ensure_public_channel(B,created_by_uid):\n-\t\tC=created_by_uid;A=await B.get(is_listed=_D,tag=_E);D=_A\n-\t\tif not A:D=_D;A=await B.create(_E,created_by_uid=C,is_listed=_D,tag=_E)\n-\t\tawait B.app.services.channel_member.create(A[_C],C,is_moderator=D,is_read_only=_A,is_muted=_A,is_banned=_A);return A\n\\ No newline at end of file\n+    mapper_name = \"channel\"\n+\n+    async def get(self, uid=None, **kwargs):\n+        if uid:\n+            kwargs[\"uid\"] = uid\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            del kwargs[\"uid\"]\n+            kwargs[\"name\"] = uid\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            return None\n+        return await super().get(**kwargs)\n+\n+    async def create(\n+        self,\n+        label,\n+        created_by_uid,\n+        description=None,\n+        tag=None,\n+        is_private=False,\n+        is_listed=True,\n+    ):\n+        count = await self.count(deleted_at=None)\n+        if not tag and not count:\n+            tag = \"public\"\n+        model = await self.new()\n+        model[\"label\"] = label\n+        model[\"description\"] = description\n+        model[\"tag\"] = tag\n+        model[\"created_by_uid\"] = created_by_uid\n+        model[\"is_private\"] = is_private\n+        model[\"is_listed\"] = is_listed\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel: {model.errors}.\")\n+\n+    async def get_dm(self, user1, user2):\n+        channel_member = await self.services.channel_member.get_dm(user1, user2)\n+        if channel_member:\n+            return await self.get(uid=channel_member[\"channel_uid\"])\n+        channel = await self.create(\"DM\", user1, tag=\"dm\")\n+        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n+        return channel\n+\n+    async def get_users(self, channel_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_uid,\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if user:\n+                yield user\n+\n+    async def get_online_users(self, channel_uid):\n+        async for user in self.get_users(channel_uid):\n+            if not user[\"last_ping\"]:\n+                continue\n+\n+            if (\n+                datetime.fromisoformat(now())\n+                - datetime.fromisoformat(user[\"last_ping\"])\n+            ).total_seconds() < 20:\n+                yield user\n+\n+    async def get_for_user(self, user_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            user_uid=user_uid,\n+            is_banned=False,\n+            deleted_at=None,\n+        ):\n+            channel = await self.get(uid=channel_member[\"channel_uid\"])\n+            yield channel\n+\n+    async def ensure_public_channel(self, created_by_uid):\n+        model = await self.get(is_listed=True, tag=\"public\")\n+        is_moderator = False\n+        if not model:\n+            is_moderator = True\n+            model = await self.create(\n+                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n+            )\n+        await self.app.services.channel_member.create(\n+            model[\"uid\"],\n+            created_by_uid,\n+            is_moderator=is_moderator,\n+            is_read_only=False,\n+            is_muted=False,\n+            is_banned=False,\n+        )\n+        return model\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex cd3a62b..df96786 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,28 +1,74 @@\n-_C='user_uid'\n-_B='channel_uid'\n-_A=False\n from snek.system.service import BaseService\n+\n+\n class ChannelMemberService(BaseService):\n-\tmapper_name='channel_member'\n-\tasync def mark_as_read(A,channel_uid,user_uid):B=await A.get(channel_uid=channel_uid,user_uid=user_uid);B['new_count']=0;return await A.save(B)\n-\tasync def get_user_uids(A,channel_uid):\n-\t\tasync for B in A.mapper.query('SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid',{_B:channel_uid}):yield B[_C]\n-\tasync def create(B,channel_uid,user_uid,is_moderator=_A,is_read_only=_A,is_muted=_A,is_banned=_A):\n-\t\tD='label';E='is_banned';F=user_uid;C=channel_uid;A=await B.get(channel_uid=C,user_uid=F)\n-\t\tif A:\n-\t\t\tif A[E]:return _A\n-\t\t\treturn A\n-\t\tA=await B.new();G=await B.services.channel.get(uid=C);A[D]=G[D];A[_B]=C;A[_C]=F;A['is_moderator']=is_moderator;A['is_read_only']=is_read_only;A['is_muted']=is_muted;A[E]=is_banned\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create channel member: {A.errors}.\")\n-\tasync def get_dm(D,from_user,to_user):\n-\t\tE='to_user';F='from_user';A=to_user;B=from_user\n-\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n-\t\tif not B==A:return\n-\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n-\tasync def get_other_dm_user(A,channel_uid,user_uid):\n-\t\tB='uid';C=channel_uid;D=await A.get(channel_uid=C,user_uid=user_uid);F=await A.services.channel.get(uid=D[_B])\n-\t\tif F['tag']!='dm':return\n-\t\tasync for E in A.services.channel_member.find(channel_uid=C):\n-\t\t\tif E[B]!=D[B]:return await A.services.user.get(uid=E[_C])\n-\tasync def create_dm(A,channel_uid,from_user_uid,to_user_uid):B=channel_uid;C=await A.create(B,from_user_uid);await A.create(B,to_user_uid);return C\n\\ No newline at end of file\n+\n+    mapper_name = \"channel_member\"\n+\n+    async def mark_as_read(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel_member[\"new_count\"] = 0\n+        return await self.save(channel_member)\n+\n+    async def get_user_uids(self, channel_uid):\n+        async for model in self.mapper.query(\n+            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n+            {\"channel_uid\": channel_uid},\n+        ):\n+            yield model[\"user_uid\"]\n+\n+    async def create(\n+        self,\n+        channel_uid,\n+        user_uid,\n+        is_moderator=False,\n+        is_read_only=False,\n+        is_muted=False,\n+        is_banned=False,\n+    ):\n+        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        if model:\n+            if model[\"is_banned\"]:\n+                return False\n+            return model\n+        model = await self.new()\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        model[\"label\"] = channel[\"label\"]\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"is_moderator\"] = is_moderator\n+        model[\"is_read_only\"] = is_read_only\n+        model[\"is_muted\"] = is_muted\n+        model[\"is_banned\"] = is_banned\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n+\n+    async def get_dm(self, from_user, to_user):\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+            return model\n+        if not from_user == to_user:\n+            return None\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+\n+            return model\n+\n+    async def get_other_dm_user(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n+            if model[\"uid\"] != channel_member[\"uid\"]:\n+                return await self.services.user.get(uid=model[\"user_uid\"])\n+\n+    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n+        result = await self.create(channel_uid, from_user_uid)\n+        await self.create(channel_uid, to_user_uid)\n+        return result\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 1841024..f8a000f 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,33 +1,93 @@\n-_I='user_nick'\n-_H='created_at'\n-_G='html'\n-_F='uid'\n-_E='message'\n-_D='color'\n-_C='username'\n-_B='user_uid'\n-_A='channel_uid'\n from snek.system.service import BaseService\n+\n+\n class ChannelMessageService(BaseService):\n-\tmapper_name='channel_message'\n-\tasync def create(B,channel_uid,user_uid,message):\n-\t\tE=user_uid;A=await B.new();A[_A]=channel_uid;A[_B]=E;A[_E]=message;D={};F=A.record;D.update(F);C=await B.app.services.user.get(uid=E);D.update({_B:C[_F],_C:C[_C],_I:C['nick'],_D:C[_D]})\n-\t\ttry:G=B.app.jinja2_env.get_template('message.html');A[_G]=G.render(**D)\n-\t\texcept Exception as H:print(H,flush=True)\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create channel message: {A.errors}.\")\n-\tasync def to_extended_dict(C,message):\n-\t\tA=message;B=await C.services.user.get(uid=A[_B])\n-\t\tif not B:return{}\n-\t\treturn{_F:A[_F],_D:B[_D],_B:A[_B],_A:A[_A],_I:B['nick'],_E:A[_E],_H:A[_H],_G:A[_G],_C:B[_C]}\n-\tasync def offset(D,channel_uid,page=0,timestamp=None,page_size=30):\n-\t\tJ='timestamp';E='offset';F='page_size';G=timestamp;H=channel_uid;C=page_size;A=[];I=page*C\n-\t\ttry:\n-\t\t\tif G:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I,J:G}):A.append(B)\n-\t\t\telif page>0:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size',{_A:H,F:C,E:I,J:G}):A.append(B)\n-\t\t\telse:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I}):A.append(B)\n-\t\texcept:pass\n-\t\tA.sort(key=lambda x:x[_H]);return A\n\\ No newline at end of file\n+    mapper_name = \"channel_message\"\n+\n+    async def create(self, channel_uid, user_uid, message):\n+        model = await self.new()\n+\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n+\n+        context = {}\n+\n+        record = model.record\n+        context.update(record)\n+        user = await self.app.services.user.get(uid=user_uid)\n+        context.update(\n+            {\n+                \"user_uid\": user[\"uid\"],\n+                \"username\": user[\"username\"],\n+                \"user_nick\": user[\"nick\"],\n+                \"color\": user[\"color\"],\n+            }\n+        )\n+        try:\n+            template = self.app.jinja2_env.get_template(\"message.html\")\n+            model[\"html\"] = template.render(**context)\n+        except Exception as ex:\n+            print(ex, flush=True)\n+\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n+\n+    async def to_extended_dict(self, message):\n+        user = await self.services.user.get(uid=message[\"user_uid\"])\n+        if not user:\n+            return {}\n+        return {\n+            \"uid\": message[\"uid\"],\n+            \"color\": user[\"color\"],\n+            \"user_uid\": message[\"user_uid\"],\n+            \"channel_uid\": message[\"channel_uid\"],\n+            \"user_nick\": user[\"nick\"],\n+            \"message\": message[\"message\"],\n+            \"created_at\": message[\"created_at\"],\n+            \"html\": message[\"html\"],\n+            \"username\": user[\"username\"],\n+        }\n+\n+    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n+        results = []\n+        offset = page * page_size\n+        try:\n+            if timestamp:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n+                    results.append(model)\n+            elif page > 0:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n+                    results.append(model)\n+            else:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                    },\n+                ):\n+                    results.append(model)\n+\n+        except:\n+            pass\n+        results.sort(key=lambda x: x[\"created_at\"])\n+        return results\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 14a9ad1..388d5c0 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,7 +1,39 @@\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class ChatService(BaseService):\n-\tasync def send(A,user_uid,channel_uid,message):\n-\t\tH='username';I='created_at';J='color';K='html';L='message';D='uid';E=user_uid;C=channel_uid;F=await A.services.channel.get(uid=C)\n-\t\tif not F:raise Exception('Channel not found.')\n-\t\tB=await A.services.channel_message.create(C,E,message);M=B[D];G=await A.services.user.get(uid=E);F['last_message_on']=now();await A.services.channel.save(F);await A.services.socket.broadcast(C,{L:B[L],K:B[K],'user_uid':E,J:G[J],'channel_uid':C,I:B[I],'updated_at':None,H:G[H],D:B[D],'user_nick':G['nick']});await A.app.create_task(A.services.notification.create_channel_message(M));return True\n\\ No newline at end of file\n+\n+    async def send(self, user_uid, channel_uid, message):\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        if not channel:\n+            raise Exception(\"Channel not found.\")\n+        channel_message = await self.services.channel_message.create(\n+            channel_uid, user_uid, message\n+        )\n+        channel_message_uid = channel_message[\"uid\"]\n+\n+        user = await self.services.user.get(uid=user_uid)\n+        channel[\"last_message_on\"] = now()\n+        await self.services.channel.save(channel)\n+\n+        await self.services.socket.broadcast(\n+            channel_uid,\n+            {\n+                \"message\": channel_message[\"message\"],\n+                \"html\": channel_message[\"html\"],\n+                \"user_uid\": user_uid,\n+                \"color\": user[\"color\"],\n+                \"channel_uid\": channel_uid,\n+                \"created_at\": channel_message[\"created_at\"],\n+                \"updated_at\": None,\n+                \"username\": user[\"username\"],\n+                \"uid\": channel_message[\"uid\"],\n+                \"user_nick\": user[\"nick\"],\n+            },\n+        )\n+        await self.app.create_task(\n+            self.services.notification.create_channel_message(channel_message_uid)\n+        )\n+\n+        return True\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex e38b3fa..38035c7 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -1,41 +1,153 @@\n-_H='Documents'\n-_G='Archives'\n-_F='Videos'\n-_E='Pictures'\n-_D='uid'\n-_C='user_uid'\n-_B='My Drive'\n-_A='name'\n from snek.system.service import BaseService\n+\n+\n class DriveService(BaseService):\n-\tmapper_name='drive';EXTENSIONS_PICTURES=['jpg','jpeg','png','gif','svg','webp','tiff'];EXTENSIONS_VIDEOS=['mp4','m4v','mov','wmv','webm','mkv','mpg','mpeg','avi','ogv','ogg','flv','3gp','3g2'];EXTENSIONS_ARCHIVES=['zip','rar','7z','tar','tar.gz','tar.xz','tar.bz2','tar.lzma','tar.lz'];EXTENSIONS_AUDIO=['mp3','wav','ogg','flac','m4a','wma','aac','opus','aiff','au','mid','midi'];EXTENSIONS_DOCS=['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','json','csv','xml','html','css','js','py','sql','rs','toml','yml','yaml','ini','conf','config','log','csv','tsv','java','cs','csproj','scss','less','sass','json','lock','lock.json','jsonl']\n-\tasync def get_drive_name_by_extension(B,extension):\n-\t\tA=extension\n-\t\tif A.startswith('.'):A=A[1:]\n-\t\tif A in B.EXTENSIONS_PICTURES:return _E\n-\t\tif A in B.EXTENSIONS_VIDEOS:return _F\n-\t\tif A in B.EXTENSIONS_ARCHIVES:return _G\n-\t\tif A in B.EXTENSIONS_AUDIO:return'Audio'\n-\t\tif A in B.EXTENSIONS_DOCS:return _H\n-\t\treturn _B\n-\tasync def get_drive_by_extension(A,user_uid,extension):B=await A.get_drive_name_by_extension(extension);return await A.get_or_create(user_uid=user_uid,name=B)\n-\tasync def get_by_user(C,user_uid,name=None):\n-\t\tB=name;D={_C:user_uid}\n-\t\tasync for A in C.find(**D):\n-\t\t\tif not B:yield A\n-\t\t\telif A[_A]==B:yield A\n-\t\t\telif not A[_A]and B==_B:A[_A]=_B;await C.save(A);yield A\n-\tasync def get_or_create(B,user_uid,name=None,extensions=None):\n-\t\tD=user_uid;C=name;E={_C:D}\n-\t\tif C:E[_A]=C\n-\t\tasync for A in B.get_by_user(**E):return A\n-\t\tA=await B.new();A[_C]=D;A[_A]=C;await B.save(A);return A\n-\tasync def prepare_default_drives(B):\n-\t\tC='drive_uid'\n-\t\tasync for A in B.services.drive_item.find():\n-\t\t\tE=A.extension;D=await B.get_drive_by_extension(A[_C],E)\n-\t\t\tif not A[C]==D[_D]:A[C]=D[_D];await B.services.drive_item.save(A)\n-\tasync def prepare_default_drives_for_user(A,user_uid):B=user_uid;await A.get_or_create(user_uid=B,name=_B);await A.get_or_create(user_uid=B,name='Shared Drive');await A.get_or_create(user_uid=B,name=_E);await A.get_or_create(user_uid=B,name=_F);await A.get_or_create(user_uid=B,name=_G);await A.get_or_create(user_uid=B,name=_H)\n-\tasync def prepare_all(A):\n-\t\tawait A.prepare_default_drives()\n-\t\tasync for B in A.services.user.find():await A.prepare_default_drives_for_user(B[_D])\n\\ No newline at end of file\n+\n+    mapper_name = \"drive\"\n+\n+    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n+    EXTENSIONS_VIDEOS = [\n+        \"mp4\",\n+        \"m4v\",\n+        \"mov\",\n+        \"wmv\",\n+        \"webm\",\n+        \"mkv\",\n+        \"mpg\",\n+        \"mpeg\",\n+        \"avi\",\n+        \"ogv\",\n+        \"ogg\",\n+        \"flv\",\n+        \"3gp\",\n+        \"3g2\",\n+    ]\n+    EXTENSIONS_ARCHIVES = [\n+        \"zip\",\n+        \"rar\",\n+        \"7z\",\n+        \"tar\",\n+        \"tar.gz\",\n+        \"tar.xz\",\n+        \"tar.bz2\",\n+        \"tar.lzma\",\n+        \"tar.lz\",\n+    ]\n+    EXTENSIONS_AUDIO = [\n+        \"mp3\",\n+        \"wav\",\n+        \"ogg\",\n+        \"flac\",\n+        \"m4a\",\n+        \"wma\",\n+        \"aac\",\n+        \"opus\",\n+        \"aiff\",\n+        \"au\",\n+        \"mid\",\n+        \"midi\",\n+    ]\n+    EXTENSIONS_DOCS = [\n+        \"pdf\",\n+        \"doc\",\n+        \"docx\",\n+        \"xls\",\n+        \"xlsx\",\n+        \"ppt\",\n+        \"pptx\",\n+        \"txt\",\n+        \"md\",\n+        \"json\",\n+        \"csv\",\n+        \"xml\",\n+        \"html\",\n+        \"css\",\n+        \"js\",\n+        \"py\",\n+        \"sql\",\n+        \"rs\",\n+        \"toml\",\n+        \"yml\",\n+        \"yaml\",\n+        \"ini\",\n+        \"conf\",\n+        \"config\",\n+        \"log\",\n+        \"csv\",\n+        \"tsv\",\n+        \"java\",\n+        \"cs\",\n+        \"csproj\",\n+        \"scss\",\n+        \"less\",\n+        \"sass\",\n+        \"json\",\n+        \"lock\",\n+        \"lock.json\",\n+        \"jsonl\",\n+    ]\n+\n+    async def get_drive_name_by_extension(self, extension):\n+        if extension.startswith(\".\"):\n+            extension = extension[1:]\n+        if extension in self.EXTENSIONS_PICTURES:\n+            return \"Pictures\"\n+        if extension in self.EXTENSIONS_VIDEOS:\n+            return \"Videos\"\n+        if extension in self.EXTENSIONS_ARCHIVES:\n+            return \"Archives\"\n+        if extension in self.EXTENSIONS_AUDIO:\n+            return \"Audio\"\n+        if extension in self.EXTENSIONS_DOCS:\n+            return \"Documents\"\n+        return \"My Drive\"\n+\n+    async def get_drive_by_extension(self, user_uid, extension):\n+        name = await self.get_drive_name_by_extension(extension)\n+        return await self.get_or_create(user_uid=user_uid, name=name)\n+\n+    async def get_by_user(self, user_uid, name=None):\n+        kwargs = {\"user_uid\": user_uid}\n+        async for model in self.find(**kwargs):\n+            if not name:\n+                yield model\n+            elif model[\"name\"] == name:\n+                yield model\n+            elif not model[\"name\"] and name == \"My Drive\":\n+                model[\"name\"] = \"My Drive\"\n+                await self.save(model)\n+                yield model\n+\n+    async def get_or_create(self, user_uid, name=None, extensions=None):\n+        kwargs = {\"user_uid\": user_uid}\n+        if name:\n+            kwargs[\"name\"] = name\n+        async for model in self.get_by_user(**kwargs):\n+            return model\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        await self.save(model)\n+        return model\n+\n+    async def prepare_default_drives(self):\n+        async for drive_item in self.services.drive_item.find():\n+            extension = drive_item.extension\n+            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n+            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n+                drive_item[\"drive_uid\"] = drive[\"uid\"]\n+                await self.services.drive_item.save(drive_item)\n+\n+    async def prepare_default_drives_for_user(self, user_uid):\n+        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n+\n+    async def prepare_all(self):\n+        await self.prepare_default_drives()\n+        async for user in self.services.user.find():\n+            await self.prepare_default_drives_for_user(user[\"uid\"])\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 0740949..ce747c1 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,7 +1,19 @@\n from snek.system.service import BaseService\n+\n+\n class DriveItemService(BaseService):\n-\tmapper_name='drive_item'\n-\tasync def create(B,drive_uid,name,path,type_,size):\n-\t\tA=await B.new();A['drive_uid']=drive_uid;A['name']=name;A['path']=str(path);A['extension']=str(name).split('.')[-1];A['file_type']=type_;A['file_size']=size\n-\t\tif await B.save(A):return A\n-\t\tC=await A.errors;raise Exception(f\"Failed to create drive item: {C}.\")\n\\ No newline at end of file\n+\n+    mapper_name = \"drive_item\"\n+\n+    async def create(self, drive_uid, name, path, type_, size):\n+        model = await self.new()\n+        model[\"drive_uid\"] = drive_uid\n+        model[\"name\"] = name\n+        model[\"path\"] = str(path)\n+        model[\"extension\"] = str(name).split(\".\")[-1]\n+        model[\"file_type\"] = type_\n+        model[\"file_size\"] = size\n+        if await self.save(model):\n+            return model\n+        errors = await model.errors\n+        raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 968d426..a22e8ae 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,28 +1,65 @@\n-_E='message'\n-_D='object_type'\n-_C='object_uid'\n-_B=False\n-_A='user_uid'\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class NotificationService(BaseService):\n-\tmapper_name='notification'\n-\tasync def mark_as_read(B,user_uid,channel_message_uid):\n-\t\tA=await B.get(user_uid,object_uid=channel_message_uid)\n-\t\tif not A:return _B\n-\t\tA['read_at']=now();await B.save(A);return True\n-\tasync def get_unread_stats(A,user_uid):await A.query('SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type',{_A:user_uid})\n-\tasync def create(B,object_uid,object_type,user_uid,message):\n-\t\tA=await B.new();A[_C]=object_uid;A[_D]=object_type;A[_A]=user_uid;A[_E]=message\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create notification: {A.errors}.\")\n-\tasync def create_channel_message(A,channel_message_uid):\n-\t\tE=channel_message_uid;D='new_count';F=await A.services.channel_message.get(uid=E);G=await A.services.user.get(uid=F[_A]);A.app.db.begin()\n-\t\tasync for B in A.services.channel_member.find(channel_uid=F['channel_uid'],is_banned=_B,is_muted=_B,deleted_at=None):\n-\t\t\tif not B[D]:B[D]=0\n-\t\t\tB[D]+=1;H=await A.services.user.get(uid=B[_A])\n-\t\t\tif not H:continue\n-\t\t\tawait A.services.channel_member.save(B);C=await A.new();C[_C]=E;C[_D]='channel_message';C[_A]=B[_A];C[_E]=f\"New message from {G[\"nick\"]} in {B[\"label\"]}.\"\n-\t\t\ttry:await A.save(C)\n-\t\t\texcept Exception:raise Exception(f\"Failed to create notification: {C.errors}.\")\n-\t\tA.app.db.commit()\n\\ No newline at end of file\n+    mapper_name = \"notification\"\n+\n+    async def mark_as_read(self, user_uid, channel_message_uid):\n+        model = await self.get(user_uid, object_uid=channel_message_uid)\n+        if not model:\n+            return False\n+        model[\"read_at\"] = now()\n+        await self.save(model)\n+        return True\n+\n+    async def get_unread_stats(self, user_uid):\n+        await self.query(\n+            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n+            {\"user_uid\": user_uid},\n+        )\n+\n+    async def create(self, object_uid, object_type, user_uid, message):\n+        model = await self.new()\n+        model[\"object_uid\"] = object_uid\n+        model[\"object_type\"] = object_type\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+    async def create_channel_message(self, channel_message_uid):\n+        channel_message = await self.services.channel_message.get(\n+            uid=channel_message_uid\n+        )\n+        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        self.app.db.begin()\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            if not channel_member[\"new_count\"]:\n+                channel_member[\"new_count\"] = 0\n+            channel_member[\"new_count\"] += 1\n+\n+            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if not usr:\n+                continue\n+            await self.services.channel_member.save(channel_member)\n+\n+            model = await self.new()\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n+            try:\n+                await self.save(model)\n+            except Exception:\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+        self.app.db.commit()\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex be30602..120c232 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,23 +1,52 @@\n-_B='user_uid'\n-_A=False\n from snek.system.service import BaseService\n-import asyncio,shutil\n+import asyncio \n+import shutil\n+\n class RepositoryService(BaseService):\n-\tmapper_name='repository'\n-\tasync def delete(B,user_uid,name):\n-\t\tA=user_uid;C=asyncio.get_event_loop();D=(await B.services.user.get_repository_path(A)).joinpath(name)\n-\t\ttry:await C.run_in_executor(None,shutil.rmtree,D)\n-\t\texcept Exception as E:print(E)\n-\t\tawait super().delete(user_uid=A,name=name)\n-\tasync def exists(B,user_uid,name,**A):A[_B]=user_uid;A['name']=name;return await super().exists(**A)\n-\tasync def init(D,user_uid,name):\n-\t\tB='.git';A=await D.services.user.get_repository_path(user_uid)\n-\t\tif not A.exists():A.mkdir(parents=True)\n-\t\tA=A.joinpath(name);A=str(A)\n-\t\tif not A.endswith(B):A+=B\n-\t\tE=['git','init','--bare',A];C=await asyncio.subprocess.create_subprocess_exec(*E,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);F,G=await C.communicate();return C.returncode==0\n-\tasync def create(A,user_uid,name,is_private=_A):\n-\t\tC=name;D=user_uid\n-\t\tif await A.exists(user_uid=D,name=C):return _A\n-\t\tif not await A.init(user_uid=D,name=C):return _A\n-\t\tB=await A.new();B[_B]=D;B['name']=C;B['is_private']=is_private;return await A.save(B)\n\\ No newline at end of file\n+    mapper_name = \"repository\"\n+\n+    async def delete(self, user_uid, name):\n+        loop = asyncio.get_event_loop()\n+        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n+        try:\n+            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n+        except Exception as ex:\n+            print(ex)\n+\n+        await super().delete(user_uid=user_uid, name=name)\n+\n+\n+    async def exists(self, user_uid, name, **kwargs):\n+        kwargs[\"user_uid\"] = user_uid\n+        kwargs[\"name\"] = name\n+        return await super().exists(**kwargs)\n+\n+    async def init(self, user_uid, name):\n+        repository_path = await self.services.user.get_repository_path(user_uid)\n+        if not repository_path.exists():\n+            repository_path.mkdir(parents=True)\n+        repository_path = repository_path.joinpath(name)\n+        repository_path = str(repository_path)\n+        if not repository_path.endswith(\".git\"):\n+            repository_path += \".git\"\n+        command = ['git', 'init', '--bare', repository_path]\n+        process = await asyncio.subprocess.create_subprocess_exec(\n+            *command,\n+            stdout=asyncio.subprocess.PIPE,\n+            stderr=asyncio.subprocess.PIPE\n+        )\n+        stdout, stderr = await process.communicate()\n+        return process.returncode == 0\n+\n+    async def create(self, user_uid, name,is_private=False):\n+        if await self.exists(user_uid=user_uid, name=name):\n+            return False \n+\n+        if not await self.init(user_uid=user_uid, name=name):\n+            return False\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        model[\"is_private\"] = is_private\n+        return await self.save(model)\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 86c83a8..a3654d2 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,36 +1,71 @@\n-_B=False\n-_A=True\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n+\n+\n class SocketService(BaseService):\n-\tclass Socket:\n-\t\tdef __init__(A,ws,user):A.ws=ws;A.is_connected=_A;A.user=user\n-\t\tasync def send_json(A,data):\n-\t\t\tif not A.is_connected:return _B\n-\t\t\ttry:await A.ws.send_json(data)\n-\t\t\texcept Exception:A.is_connected=_B\n-\t\t\treturn A.is_connected\n-\t\tasync def close(A):\n-\t\t\tif not A.is_connected:return _A\n-\t\t\tawait A.ws.close();A.is_connected=_B;return _A\n-\tdef __init__(A,app):super().__init__(app);A.sockets=set();A.users={};A.subscriptions={}\n-\tasync def add(A,ws,user_uid):\n-\t\tB=user_uid;C=A.Socket(ws,await A.app.services.user.get(uid=B));A.sockets.add(C)\n-\t\tif not A.users.get(B):A.users[B]=set()\n-\t\tA.users[B].add(C)\n-\tasync def subscribe(A,ws,channel_uid,user_uid):\n-\t\tB=channel_uid\n-\t\tif B not in A.subscriptions:A.subscriptions[B]=set()\n-\t\tC=A.Socket(ws,await A.app.services.user.get(uid=user_uid));A.subscriptions[B].add(C)\n-\tasync def send_to_user(B,user_uid,message):\n-\t\tA=0\n-\t\tfor C in B.users.get(user_uid,[]):\n-\t\t\tif await C.send_json(message):A+=1\n-\t\treturn A\n-\tasync def broadcast(A,channel_uid,message):\n-\t\ttry:\n-\t\t\tasync for B in A.services.channel_member.get_user_uids(channel_uid):print(B,flush=_A);await A.send_to_user(B,message)\n-\t\texcept Exception as C:print(C,flush=_A)\n-\t\treturn _A\n-\tasync def delete(A,ws):\n-\t\tfor B in[A for A in A.sockets if A.ws==ws]:await B.close();A.sockets.remove(B)\n\\ No newline at end of file\n+\n+    class Socket:\n+        def __init__(self, ws, user: UserModel):\n+            self.ws = ws\n+            self.is_connected = True\n+            self.user = user\n+\n+        async def send_json(self, data):\n+            if not self.is_connected:\n+                return False\n+            try:\n+                await self.ws.send_json(data)\n+            except Exception:\n+                self.is_connected = False\n+            return self.is_connected\n+\n+        async def close(self):\n+            if not self.is_connected:\n+                return True\n+\n+            await self.ws.close()\n+            self.is_connected = False\n+\n+            return True\n+\n+    def __init__(self, app):\n+        super().__init__(app)\n+        self.sockets = set()\n+        self.users = {}\n+        self.subscriptions = {}\n+\n+    async def add(self, ws, user_uid):\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.sockets.add(s)\n+        if not self.users.get(user_uid):\n+            self.users[user_uid] = set()\n+        self.users[user_uid].add(s)\n+\n+    async def subscribe(self, ws, channel_uid, user_uid):\n+        if channel_uid not in self.subscriptions:\n+            self.subscriptions[channel_uid] = set()\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.subscriptions[channel_uid].add(s)\n+\n+    async def send_to_user(self, user_uid, message):\n+        count = 0\n+        for s in self.users.get(user_uid, []):\n+            if await s.send_json(message):\n+                count += 1\n+        return count\n+\n+    async def broadcast(self, channel_uid, message):\n+        try:\n+            async for user_uid in self.services.channel_member.get_user_uids(\n+                channel_uid\n+            ):\n+                print(user_uid, flush=True)\n+                await self.send_to_user(user_uid, message)\n+        except Exception as ex:\n+            print(ex, flush=True)\n+        return True\n+\n+    async def delete(self, ws):\n+        for s in [sock for sock in self.sockets if sock.ws == ws]:\n+            await s.close()\n+            self.sockets.remove(s)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6ece3fd..76e6d1c 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,53 +1,91 @@\n-_B='color'\n-_A=True\n import pathlib\n+\n from snek.system import security\n from snek.system.service import BaseService\n+\n+\n class UserService(BaseService):\n-\tmapper_name='user'\n-\tasync def get_by_username(A,username):return await A.get(username=username)\n-\tasync def search(C,query,**D):\n-\t\tA=query;A=A.strip().lower()\n-\t\tif not A:return[]\n-\t\tB=[]\n-\t\tasync for E in C.find(username={'ilike':'%'+A+'%'},**D):B.append(E)\n-\t\treturn B\n-\tasync def validate_login(C,username,password):\n-\t\tA=False;B=await C.get(username=username)\n-\t\tif not B:return A\n-\t\tif not await security.verify(password,B['password']):return A\n-\t\treturn _A\n-\tasync def save(B,user):\n-\t\tA=user\n-\t\tif not A[_B]:A[_B]=await B.services.util.random_light_hex_color()\n-\t\treturn await super().save(A)\n-\tasync def authenticate(B,username,password):\n-\t\tC=password;A=username;print(A,C,flush=_A);D=await B.validate_login(A,C);print(D,flush=_A)\n-\t\tif not D:return\n-\t\tE=await B.get(username=A,deleted_at=None);return E\n-\tdef get_admin_uids(A):return A.mapper.get_admin_uids()\n-\tasync def get_repository_path(A,user_uid):return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-\tasync def get_static_path(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n-\t\tif not A.exists():return\n-\t\treturn A\n-\tasync def get_template_path(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n-\t\tif not A.exists():return\n-\t\treturn A\n-\tasync def get_home_folder(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}\")\n-\t\tif not A.exists():\n-\t\t\ttry:A.mkdir(parents=_A,exist_ok=_A)\n-\t\t\texcept:pass\n-\t\treturn A\n-\tasync def register(B,email,username,password):\n-\t\tC=username\n-\t\tif await B.exists(username=C):raise Exception('User already exists.')\n-\t\tA=await B.new();A['nick']=C;A[_B]=await B.services.util.random_light_hex_color();A.email.value=email;A.username.value=C;A.password.value=await security.hash(password)\n-\t\tif await B.save(A):\n-\t\t\tif A:\n-\t\t\t\tD=await B.services.channel.ensure_public_channel(A['uid'])\n-\t\t\t\tif not D:raise Exception('Failed to create public channel.')\n-\t\t\treturn A\n-\t\traise Exception(f\"Failed to create user: {A.errors}.\")\n\\ No newline at end of file\n+    mapper_name = \"user\"\n+\n+    async def get_by_username(self, username):\n+        return await self.get(username=username)\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            return []\n+        results = []\n+        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\n+\n+    async def validate_login(self, username, password):\n+        model = await self.get(username=username)\n+        if not model:\n+            return False\n+        if not await security.verify(password, model[\"password\"]):\n+            return False\n+        return True\n+\n+    async def save(self, user):\n+        if not user[\"color\"]:\n+            user[\"color\"] = await self.services.util.random_light_hex_color()\n+        return await super().save(user)\n+\n+    async def authenticate(self, username, password):\n+        print(username, password, flush=True)\n+        success = await self.validate_login(username, password)\n+        print(success, flush=True)\n+        if not success:\n+            return None\n+\n+        model = await self.get(username=username, deleted_at=None)\n+        return model\n+\n+    def get_admin_uids(self):\n+        return self.mapper.get_admin_uids()\n+\n+    async def get_repository_path(self, user_uid):\n+        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+\n+    async def get_static_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+\n+\n+    async def get_template_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+    async def get_home_folder(self, user_uid):\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        if not folder.exists():\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n+        return folder\n+\n+    async def register(self, email, username, password):\n+        if await self.exists(username=username):\n+            raise Exception(\"User already exists.\")\n+        model = await self.new()\n+        model[\"nick\"] = username\n+        model[\"color\"] = await self.services.util.random_light_hex_color()\n+        model.email.value = email\n+        model.username.value = username\n+        model.password.value = await security.hash(password)\n+        if await self.save(model):\n+            if model:\n+                channel = await self.services.channel.ensure_public_channel(\n+                    model[\"uid\"]\n+                )\n+                if not channel:\n+                    raise Exception(\"Failed to create public channel.\")\n+            return model\n+        raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex da9136a..4d11fa8 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,15 +1,35 @@\n-_A='user_property'\n import json\n+\n from snek.system.service import BaseService\n+\n+\n class UserPropertyService(BaseService):\n-\tmapper_name=_A\n-\tasync def set(C,user_uid,name,value):A='name';B='user_uid';C.mapper.db[_A].upsert({B:user_uid,A:name,'value':json.dumps(value,default=str)},[B,A])\n-\tasync def get(B,user_uid,name):\n-\t\ttry:return json.loads((await super().get(user_uid=user_uid,name=name))['value'])\n-\t\texcept Exception as A:print(A);return\n-\tasync def search(C,query,**D):\n-\t\tA=query;A=A.strip().lower()\n-\t\tif not A:raise[]\n-\t\tB=[]\n-\t\tasync for E in C.find(name={'ilike':'%'+A+'%'},**D):B.append(E)\n-\t\treturn B\n\\ No newline at end of file\n+    mapper_name = \"user_property\"\n+\n+    async def set(self, user_uid, name, value):\n+        self.mapper.db[\"user_property\"].upsert(\n+            {\n+                \"user_uid\": user_uid,\n+                \"name\": name,\n+                \"value\": json.dumps(value, default=str),\n+            },\n+            [\"user_uid\", \"name\"],\n+        )\n+\n+    async def get(self, user_uid, name):\n+        try:\n+            return json.loads(\n+                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            return None\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex 73dbec3..b620d9c 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,4 +1,14 @@\n import random\n+\n from snek.system.service import BaseService\n+\n+\n class UtilService(BaseService):\n\\ No newline at end of file\n+\n+    async def random_light_hex_color(self):\n+\n+        r = random.randint(128, 255)\n+        g = random.randint(128, 255)\n+        b = random.randint(128, 255)\n+\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 0f3a69f..f8bfeb7 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,207 +1,489 @@\n-_O='branches'\n-_N='message'\n-_M='author'\n-_L='Invalid JSON data'\n-_K='origin'\n-_J='Repository not found'\n-_I='main'\n-_H='repository'\n-_G='branch'\n-_F='.git'\n-_E=None\n-_D='user'\n-_C='repo_name'\n-_B='username'\n-_A='repository_path'\n-import os,aiohttp\n+import os\n+import aiohttp\n from aiohttp import web\n-import git,shutil,json,tempfile,asyncio,logging,base64,pathlib\n-logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n-logger=logging.getLogger('git_server')\n+import git\n+import shutil\n+import json\n+import tempfile\n+import asyncio\n+import logging\n+import base64\n+import pathlib\n+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger = logging.getLogger('git_server')\n+\n class GitApplication(web.Application):\n-\tdef __init__(A,parent=_E):B='/branches/{repo_name}';A.parent=parent;super().__init__(client_max_size=5368709120);A.REPO_DIR='drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545';A.USERS={'x':'x','bob':'bobpass'};A.add_routes([web.post('/create/{repo_name}',A.create_repository),web.delete('/delete/{repo_name}',A.delete_repository),web.get('/clone/{repo_name}',A.clone_repository),web.post('/push/{repo_name}',A.push_repository),web.post('/pull/{repo_name}',A.pull_repository),web.get('/status/{repo_name}',A.status_repository),web.get('/list',A.list_repositories),web.get(B,A.list_branches),web.post(B,A.create_branch),web.get('/log/{repo_name}',A.commit_log),web.get('/file/{repo_name}/{file_path:.*}',A.file_content),web.get('/{path:.+}/info/refs',A.git_smart_http),web.post('/{path:.+}/git-upload-pack',A.git_smart_http),web.post('/{path:.+}/git-receive-pack',A.git_smart_http),web.get('/{repo_name}.git/info/refs',A.git_smart_http),web.post('/{repo_name}.git/git-upload-pack',A.git_smart_http),web.post('/{repo_name}.git/git-receive-pack',A.git_smart_http)])\n-\tasync def check_basic_auth(B,request):\n-\t\tC='Basic ';A=request;D=A.headers.get('Authorization','')\n-\t\tif not D.startswith(C):return _E,_E\n-\t\tE=D.split(C)[1];F=base64.b64decode(E).decode();G,H=F.split(':',1);A[_D]=await B.parent.services.user.authenticate(username=G,password=H)\n-\t\tif not A[_D]:return _E,_E\n-\t\tA[_A]=await B.parent.services.user.get_repository_path(A[_D]['uid']);return A[_D][_B],A[_A]\n-\t@staticmethod\n-\tdef require_auth(handler):\n-\t\tasync def A(self,request,*D,**E):\n-\t\t\tA=request;B,C=await self.check_basic_auth(A)\n-\t\t\tif not B or not C:return web.Response(status=401,headers={'WWW-Authenticate':'Basic'},text='Authentication required')\n-\t\t\tA[_B]=B;A[_A]=C;return await handler(self,A,*D,**E)\n-\t\treturn A\n-\tdef repo_path(A,repository_path,repo_name):return repository_path.joinpath(repo_name+_F)\n-\tdef check_repo_exists(A,repository_path,repo_name):\n-\t\tB=A.repo_path(repository_path,repo_name)\n-\t\tif not os.path.exists(B):return web.Response(text=_J,status=404)\n-\t@require_auth\n-\tasync def create_repository(self,request):\n-\t\tB=request;E=B[_B];A=B.match_info[_C];F=B[_A]\n-\t\tif not A or'/'in A or'..'in A:return web.Response(text='Invalid repository name',status=400)\n-\t\tC=self.repo_path(F,A)\n-\t\tif os.path.exists(C):return web.Response(text='Repository already exists',status=400)\n-\t\ttry:git.Repo.init(C,bare=True);logger.info(f\"Created repository: {A} for user {E}\");return web.Response(text=f\"Created repository {A}\")\n-\t\texcept Exception as D:logger.error(f\"Error creating repository {A}: {str(D)}\");return web.Response(text=f\"Error creating repository: {str(D)}\",status=500)\n-\t@require_auth\n-\tasync def delete_repository(self,request):\n-\t\tB=request;F=B[_B];A=B.match_info[_C];C=B[_A];D=self.check_repo_exists(C,A)\n-\t\tif D:return D\n-\t\ttry:shutil.rmtree(self.repo_path(C,A));logger.info(f\"Deleted repository: {A} for user {F}\");return web.Response(text=f\"Deleted repository {A}\")\n-\t\texcept Exception as E:logger.error(f\"Error deleting repository {A}: {str(E)}\");return web.Response(text=f\"Error deleting repository: {str(E)}\",status=500)\n-\t@require_auth\n-\tasync def clone_repository(self,request):\n-\t\tA=request;H=A[_B];B=A.match_info[_C];E=A[_A];C=self.check_repo_exists(E,B)\n-\t\tif C:return C\n-\t@require_auth\n-\tasync def push_repository(self,request):\n-\t\tB=request;L=B[_B];C=B.match_info[_C];E=B[_A];F=self.check_repo_exists(E,C)\n-\t\tif F:return F\n-\t\ttry:D=await B.json()\n-\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n-\t\tM=D.get('commit_message','Update from server');G=D.get(_G,_I);H=D.get('changes',[])\n-\t\tif not H:return web.Response(text='No changes provided',status=400)\n-\t\twith tempfile.TemporaryDirectory()as I:\n-\t\t\tA=git.Repo.clone_from(self.repo_path(E,C),I)\n-\t\t\tfor J in H:\n-\t\t\t\tK=os.path.join(I,J.get('file',''));N=J.get('content','');os.makedirs(os.path.dirname(K),exist_ok=True)\n-\t\t\t\twith open(K,'w')as O:O.write(N)\n-\t\t\tA.git.add(A=True)\n-\t\t\tif not A.config_reader().has_section(_D):A.config_writer().set_value(_D,'name','Git Server').release();A.config_writer().set_value(_D,'email','git@server.local').release()\n-\t\t\tA.index.commit(M);P=A.remote(_K);P.push(refspec=f\"{G}:{G}\")\n-\t\tlogger.info(f\"Pushed to repository: {C} for user {L}\");return web.Response(text=f\"Successfully pushed changes to {C}\")\n-\t@require_auth\n-\tasync def pull_repository(self,request):\n-\t\tC=request;K=C[_B];A=C.match_info[_C];H=C[_A];I=self.check_repo_exists(H,A)\n-\t\tif I:return I\n-\t\ttry:E=await C.json()\n-\t\texcept json.JSONDecodeError:E={}\n-\t\tB=E.get('remote_url');L=E.get(_G,_I)\n-\t\tif not B:return web.Response(text='Remote URL is required',status=400)\n-\t\twith tempfile.TemporaryDirectory()as M:\n-\t\t\ttry:\n-\t\t\t\tD=git.Repo.clone_from(self.repo_path(H,A),M);F='pull_source'\n-\t\t\t\ttry:G=D.create_remote(F,B)\n-\t\t\t\texcept git.GitCommandError:G=D.remote(F);G.set_url(B)\n-\t\t\t\tG.fetch();D.git.merge(f\"{F}/{L}\");N=D.remote(_K);N.push();logger.info(f\"Pulled to repository {A} from {B} for user {K}\");return web.Response(text=f\"Successfully pulled changes from {B} to {A}\")\n-\t\t\texcept Exception as J:logger.error(f\"Error pulling to {A}: {str(J)}\");return web.Response(text=f\"Error pulling changes: {str(J)}\",status=500)\n-\t@require_auth\n-\tasync def status_repository(self,request):\n-\t\tC=request;S=C[_B];B=C.match_info[_C];F=C[_A];G=self.check_repo_exists(F,B)\n-\t\tif G:return G\n-\t\twith tempfile.TemporaryDirectory()as D:\n-\t\t\ttry:\n-\t\t\t\tE=git.Repo.clone_from(self.repo_path(F,B),D);L=[A.name for A in E.branches];M=E.active_branch.name;H=[]\n-\t\t\t\tfor A in list(E.iter_commits(max_count=5)):H.append({'id':A.hexsha,_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message})\n-\t\t\t\tI=[]\n-\t\t\t\tfor(J,T,N)in os.walk(D):\n-\t\t\t\t\tif _F in J:continue\n-\t\t\t\t\tfor O in N:P=os.path.join(J,O);Q=os.path.relpath(P,D);I.append(Q)\n-\t\t\t\tR={_H:B,_O:L,'active_branch':M,'recent_commits':H,'files':I};return web.json_response(R)\n-\t\t\texcept Exception as K:logger.error(f\"Error getting status for {B}: {str(K)}\");return web.Response(text=f\"Error getting repository status: {str(K)}\",status=500)\n-\t@require_auth\n-\tasync def list_repositories(self,request):\n-\t\tD=request;G=D[_B]\n-\t\ttry:\n-\t\t\tA=[];B=self.REPO_DIR\n-\t\t\tif os.path.exists(B):\n-\t\t\t\tfor C in os.listdir(B):\n-\t\t\t\t\tF=os.path.join(B,C)\n-\t\t\t\t\tif os.path.isdir(F)and C.endswith(_F):A.append(C[:-4])\n-\t\t\tif D.query.get('format')=='json':return web.json_response({'repositories':A})\n-\t\t\telse:return web.Response(text='\\n'.join(A)if A else'No repositories found')\n-\t\texcept Exception as E:logger.error(f\"Error listing repositories: {str(E)}\");return web.Response(text=f\"Error listing repositories: {str(E)}\",status=500)\n-\t@require_auth\n-\tasync def list_branches(self,request):\n-\t\tA=request;H=A[_B];B=A.match_info[_C];C=A[_A];D=self.check_repo_exists(C,B)\n-\t\tif D:return D\n-\t\twith tempfile.TemporaryDirectory()as E:F=git.Repo.clone_from(self.repo_path(C,B),E);G=[A.name for A in F.branches];return web.json_response({_O:G})\n-\t@require_auth\n-\tasync def create_branch(self,request):\n-\t\tB=request;I=B[_B];C=B.match_info[_C];D=B[_A];E=self.check_repo_exists(D,C)\n-\t\tif E:return E\n-\t\ttry:F=await B.json()\n-\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n-\t\tA=F.get('branch_name');J=F.get('start_point','HEAD')\n-\t\tif not A:return web.Response(text='Branch name is required',status=400)\n-\t\twith tempfile.TemporaryDirectory()as K:\n-\t\t\ttry:G=git.Repo.clone_from(self.repo_path(D,C),K);G.git.branch(A,J);G.git.push(_K,A);logger.info(f\"Created branch {A} in repository {C} for user {I}\");return web.Response(text=f\"Created branch {A}\")\n-\t\t\texcept Exception as H:logger.error(f\"Error creating branch {A} in {C}: {str(H)}\");return web.Response(text=f\"Error creating branch: {str(H)}\",status=500)\n-\t@require_auth\n-\tasync def commit_log(self,request):\n-\t\tB=request;L=B[_B];C=B.match_info[_C];F=B[_A];G=self.check_repo_exists(F,C)\n-\t\tif G:return G\n-\t\ttry:I=int(B.query.get('limit',10));H=B.query.get(_G,_I)\n-\t\texcept ValueError:return web.Response(text='Invalid limit parameter',status=400)\n-\t\twith tempfile.TemporaryDirectory()as J:\n-\t\t\ttry:\n-\t\t\t\tK=git.Repo.clone_from(self.repo_path(F,C),J);E=[]\n-\t\t\t\ttry:\n-\t\t\t\t\tfor A in list(K.iter_commits(H,max_count=I)):E.append({'id':A.hexsha,'short_id':A.hexsha[:7],_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message.strip()})\n-\t\t\t\texcept git.GitCommandError as D:\n-\t\t\t\t\tif'unknown revision or path'in str(D):E=[]\n-\t\t\t\t\telse:raise\n-\t\t\t\treturn web.json_response({_H:C,_G:H,'commits':E})\n-\t\t\texcept Exception as D:logger.error(f\"Error getting commit log for {C}: {str(D)}\");return web.Response(text=f\"Error getting commit log: {str(D)}\",status=500)\n-\t@require_auth\n-\tasync def file_content(self,request):\n-\t\tA=request;N=A[_B];B=A.match_info[_C];C=A.match_info.get('file_path','');E=A.query.get(_G,_I);F=A[_A];G=self.check_repo_exists(F,B)\n-\t\tif G:return G\n-\t\twith tempfile.TemporaryDirectory()as H:\n-\t\t\ttry:\n-\t\t\t\tJ=git.Repo.clone_from(self.repo_path(F,B),H)\n-\t\t\t\ttry:J.git.checkout(E)\n-\t\t\t\texcept git.GitCommandError:return web.Response(text=f\"Branch '{E}' not found\",status=404)\n-\t\t\t\tD=os.path.join(H,C)\n-\t\t\t\tif not os.path.exists(D):return web.Response(text=f\"File '{C}' not found\",status=404)\n-\t\t\t\tif os.path.isdir(D):K=os.listdir(D);return web.json_response({_H:B,'path':C,'type':'directory','contents':K})\n-\t\t\t\telse:\n-\t\t\t\t\ttry:\n-\t\t\t\t\t\twith open(D,'r')as L:M=L.read()\n-\t\t\t\t\t\treturn web.Response(text=M)\n-\t\t\t\t\texcept UnicodeDecodeError:return web.Response(text=f\"Cannot display binary file content for '{C}'\",status=400)\n-\t\t\texcept Exception as I:logger.error(f\"Error getting file content from {B}: {str(I)}\");return web.Response(text=f\"Error getting file content: {str(I)}\",status=500)\n-\t@require_auth\n-\tasync def git_smart_http(self,request):\n-\t\tB='POST';G='git-receive-pack';H='git-upload-pack';I='Content-Type';J='--stateless-rpc';D='/git-receive-pack';E='/git-upload-pack';F='/info/refs';A=request;P=A[_B];N=A[_A];C=A.path\n-\t\tasync def K():\n-\t\t\tB=C.lstrip('/')\n-\t\t\tif B.endswith(F):A=B[:-len(F)]\n-\t\t\telif B.endswith(E):A=B[:-len(E)]\n-\t\t\telif B.endswith(D):A=B[:-len(D)]\n-\t\t\telse:A=B\n-\t\t\tif A.endswith(_F):A=A[:-4]\n-\t\t\tA=A[4:];G=N.joinpath(A+_F);logger.info(f\"Resolved repo path: {G}\");return G\n-\t\tasync def O(service):\n-\t\t\tC=service;D=await K();logger.info(f\"handle_info_refs: {D}\")\n-\t\t\tif not os.path.exists(D):return web.Response(text=_J,status=404)\n-\t\t\tL=[C,J,'--advertise-refs',str(D)]\n-\t\t\ttry:\n-\t\t\t\tE=await asyncio.create_subprocess_exec(*L,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);M,F=await E.communicate()\n-\t\t\t\tif E.returncode!=0:logger.error(f\"Git command failed: {F.decode()}\");return web.Response(text=f\"Git error: {F.decode()}\",status=500)\n-\t\t\texcept Exception as H:logger.error(f\"Error handling info/refs: {str(H)}\");return web.Response(text=f\"Server error: {str(H)}\",status=500)\n-\t\tasync def L(service):\n-\t\t\tB=service;C=await K();logger.info(f\"handle_service_rpc: {C}\")\n-\t\t\tif not os.path.exists(C):return web.Response(text=_J,status=404)\n-\t\t\tif not A.headers.get(I)==f\"application/x-{B}-request\":return web.Response(text='Invalid Content-Type',status=403)\n-\t\t\tG=await A.read();H=[B,J,str(C)]\n-\t\t\ttry:\n-\t\t\t\tD=await asyncio.create_subprocess_exec(*H,stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);L,E=await D.communicate(input=G)\n-\t\t\t\tif D.returncode!=0:logger.error(f\"Git command failed: {E.decode()}\");return web.Response(text=f\"Git error: {E.decode()}\",status=500)\n-\t\t\t\treturn web.Response(body=L,content_type=f\"application/x-{B}-result\")\n-\t\t\texcept Exception as F:logger.error(f\"Error handling service RPC: {str(F)}\");return web.Response(text=f\"Server error: {str(F)}\",status=500)\n-\t\tif A.method=='GET'and C.endswith(F):\n-\t\t\tM=A.query.get('service')\n-\t\t\tif M in(H,G):return await O(M)\n-\t\t\telse:return web.Response(text='Smart HTTP requires service parameter',status=400)\n-\t\telif A.method==B and E in C:return await L(H)\n-\t\telif A.method==B and D in C:return await L(G)\n-\t\treturn web.Response(text='Not found',status=404)\n-if __name__=='__main__':\n-\ttry:import uvloop;asyncio.set_event_loop_policy(uvloop.EventLoopPolicy());logger.info('Using uvloop for improved performance')\n-\texcept ImportError:logger.info('uvloop not available, using standard event loop')\n-\tapp=GitApplication();logger.info('Starting Git server on port 8080');web.run_app(app,port=8080)\n\\ No newline at end of file\n+    def __init__(self, parent=None):\n+        self.parent = parent\n+        super().__init__(client_max_size=1024*1024*1024*5)\n+        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n+        self.USERS = {\n+            'x': 'x',\n+            'bob': 'bobpass',\n+        }\n+        self.add_routes([\n+            web.post('/create/{repo_name}', self.create_repository),\n+            web.delete('/delete/{repo_name}', self.delete_repository),\n+            web.get('/clone/{repo_name}', self.clone_repository),\n+            web.post('/push/{repo_name}', self.push_repository),\n+            web.post('/pull/{repo_name}', self.pull_repository),\n+            web.get('/status/{repo_name}', self.status_repository),\n+            web.get('/list', self.list_repositories),\n+            web.get('/branches/{repo_name}', self.list_branches),\n+            web.post('/branches/{repo_name}', self.create_branch),\n+            web.get('/log/{repo_name}', self.commit_log),\n+            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n+            web.get('/{path:.+}/info/refs', self.git_smart_http),\n+            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n+            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n+            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n+        ])\n+\n+\n+    async def check_basic_auth(self, request):\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return None,None\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.parent.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        if not request[\"user\"]:\n+            return None,None\n+        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n+            request[\"user\"][\"uid\"]\n+        )\n+\n+        return request[\"user\"]['username'],request[\"repository_path\"]\n+\n+\n+    @staticmethod\n+    def require_auth(handler):\n+        async def wrapped(self, request, *args, **kwargs):\n+            username, repository_path = await self.check_basic_auth(request)\n+            if not username or not repository_path:\n+                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n+            request['username'] = username\n+            request['repository_path'] = repository_path\n+            return await handler(self, request, *args, **kwargs)\n+        return wrapped\n+\n+    def repo_path(self, repository_path, repo_name):\n+        return repository_path.joinpath(repo_name + '.git')\n+\n+    def check_repo_exists(self, repository_path, repo_name):\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if not os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository not found\", status=404)\n+        return None\n+\n+    @require_auth\n+    async def create_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        if not repo_name or '/' in repo_name or '..' in repo_name:\n+            return web.Response(text=\"Invalid repository name\", status=400)\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository already exists\", status=400)\n+        try:\n+            git.Repo.init(repo_dir, bare=True)\n+            logger.info(f\"Created repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Created repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def delete_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            shutil.rmtree(self.repo_path(repository_path, repo_name))\n+            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Deleted repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def clone_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        host = request.host\n+        response_data = {\n+            \"repository\": repo_name,\n+            \"clone_command\": f\"git clone {clone_url}\",\n+            \"clone_url\": clone_url\n+        }\n+        return web.json_response(response_data)\n+\n+    @require_auth\n+    async def push_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        commit_message = data.get('commit_message', 'Update from server')\n+        branch = data.get('branch', 'main')\n+        changes = data.get('changes', [])\n+        if not changes:\n+            return web.Response(text=\"No changes provided\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            for change in changes:\n+                file_path = os.path.join(temp_dir, change.get('file', ''))\n+                content = change.get('content', '')\n+                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n+                with open(file_path, 'w') as f:\n+                    f.write(content)\n+            temp_repo.git.add(A=True)\n+            if not temp_repo.config_reader().has_section('user'):\n+                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n+                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n+            temp_repo.index.commit(commit_message)\n+            origin = temp_repo.remote('origin')\n+            origin.push(refspec=f\"{branch}:{branch}\")\n+        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n+        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n+\n+    @require_auth\n+    async def pull_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            data = {}\n+        remote_url = data.get('remote_url')\n+        branch = data.get('branch', 'main')\n+        if not remote_url:\n+            return web.Response(text=\"Remote URL is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                remote_name = \"pull_source\"\n+                try:\n+                    remote = local_repo.create_remote(remote_name, remote_url)\n+                except git.GitCommandError:\n+                    remote = local_repo.remote(remote_name)\n+                    remote.set_url(remote_url)\n+                remote.fetch()\n+                local_repo.git.merge(f\"{remote_name}/{branch}\")\n+                origin = local_repo.remote('origin')\n+                origin.push()\n+                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n+                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def status_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                branches = [b.name for b in temp_repo.branches]\n+                active_branch = temp_repo.active_branch.name\n+                commits = []\n+                for commit in list(temp_repo.iter_commits(max_count=5)):\n+                    commits.append({\n+                        \"id\": commit.hexsha,\n+                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                        \"date\": commit.committed_datetime.isoformat(),\n+                        \"message\": commit.message\n+                    })\n+                files = []\n+                for root, dirs, filenames in os.walk(temp_dir):\n+                    if '.git' in root:\n+                        continue\n+                    for filename in filenames:\n+                        full_path = os.path.join(root, filename)\n+                        rel_path = os.path.relpath(full_path, temp_dir)\n+                        files.append(rel_path)\n+                status_info = {\n+                    \"repository\": repo_name,\n+                    \"branches\": branches,\n+                    \"active_branch\": active_branch,\n+                    \"recent_commits\": commits,\n+                    \"files\": files\n+                }\n+                return web.json_response(status_info)\n+            except Exception as e:\n+                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_repositories(self, request):\n+        username = request['username']\n+        try:\n+            repos = []\n+            user_dir = self.REPO_DIR\n+            if os.path.exists(user_dir):\n+                for item in os.listdir(user_dir):\n+                    item_path = os.path.join(user_dir, item)\n+                    if os.path.isdir(item_path) and item.endswith('.git'):\n+                        repos.append(item[:-4])\n+            if request.query.get('format') == 'json':\n+                return web.json_response({\"repositories\": repos})\n+            else:\n+                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n+        except Exception as e:\n+            logger.error(f\"Error listing repositories: {str(e)}\")\n+            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_branches(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            branches = [b.name for b in temp_repo.branches]\n+            return web.json_response({\"branches\": branches})\n+\n+    @require_auth\n+    async def create_branch(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        branch_name = data.get('branch_name')\n+        start_point = data.get('start_point', 'HEAD')\n+        if not branch_name:\n+            return web.Response(text=\"Branch name is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                temp_repo.git.branch(branch_name, start_point)\n+                temp_repo.git.push('origin', branch_name)\n+                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n+                return web.Response(text=f\"Created branch {branch_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def commit_log(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            limit = int(request.query.get('limit', 10))\n+            branch = request.query.get('branch', 'main')\n+        except ValueError:\n+            return web.Response(text=\"Invalid limit parameter\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                commits = []\n+                try:\n+                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n+                        commits.append({\n+                            \"id\": commit.hexsha,\n+                            \"short_id\": commit.hexsha[:7],\n+                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                            \"date\": commit.committed_datetime.isoformat(),\n+                            \"message\": commit.message.strip()\n+                        })\n+                except git.GitCommandError as e:\n+                    if \"unknown revision or path\" in str(e):\n+                        commits = []\n+                    else:\n+                        raise\n+                return web.json_response({\n+                    \"repository\": repo_name,\n+                    \"branch\": branch,\n+                    \"commits\": commits\n+                })\n+            except Exception as e:\n+                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def file_content(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        file_path = request.match_info.get('file_path', '')\n+        branch = request.query.get('branch', 'main')\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                try:\n+                    temp_repo.git.checkout(branch)\n+                except git.GitCommandError:\n+                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n+                file_full_path = os.path.join(temp_dir, file_path)\n+                if not os.path.exists(file_full_path):\n+                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n+                if os.path.isdir(file_full_path):\n+                    files = os.listdir(file_full_path)\n+                    return web.json_response({\n+                        \"repository\": repo_name,\n+                        \"path\": file_path,\n+                        \"type\": \"directory\",\n+                        \"contents\": files\n+                    })\n+                else:\n+                    try:\n+                        with open(file_full_path, 'r') as f:\n+                            content = f.read()\n+                        return web.Response(text=content)\n+                    except UnicodeDecodeError:\n+                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n+            except Exception as e:\n+                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def git_smart_http(self, request):\n+        username = request['username']\n+        repository_path = request['repository_path']\n+        path = request.path\n+        async def get_repository_path():\n+            req_path = path.lstrip('/')\n+            if req_path.endswith('/info/refs'):\n+                repo_name = req_path[:-len('/info/refs')]\n+            elif req_path.endswith('/git-upload-pack'):\n+                repo_name = req_path[:-len('/git-upload-pack')]\n+            elif req_path.endswith('/git-receive-pack'):\n+                repo_name = req_path[:-len('/git-receive-pack')]\n+            else:\n+                repo_name = req_path\n+            if repo_name.endswith('.git'):\n+                repo_name = repo_name[:-4]\n+            repo_name = repo_name[4:]\n+            repo_dir = repository_path.joinpath(repo_name + \".git\")\n+            logger.info(f\"Resolved repo path: {repo_dir}\")\n+            return repo_dir \n+        async def handle_info_refs(service):\n+            repo_path = await get_repository_path()\n+        \n+            logger.info(f\"handle_info_refs: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate()\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason='OK',\n+                    headers={\n+                        'Content-Type': f'application/x-{service}-advertisement',\n+                        'Cache-Control': 'no-cache'\n+                    }\n+                )\n+                await response.prepare(request)\n+                length = len(packet) + 4\n+                header = f\"{length:04x}\"\n+                await response.write(f\"{header}{packet}0000\".encode())\n+                await response.write(stdout)\n+                return response\n+            except Exception as e:\n+                logger.error(f\"Error handling info/refs: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        async def handle_service_rpc(service):\n+            repo_path = await get_repository_path()\n+            logger.info(f\"handle_service_rpc: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n+                return web.Response(text=\"Invalid Content-Type\", status=403)\n+            body = await request.read()\n+            cmd = [service, '--stateless-rpc', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdin=asyncio.subprocess.PIPE,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate(input=body)\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                return web.Response(\n+                    body=stdout,\n+                    content_type=f'application/x-{service}-result'\n+                )\n+            except Exception as e:\n+                logger.error(f\"Error handling service RPC: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        if request.method == 'GET' and path.endswith('/info/refs'):\n+            service = request.query.get('service')\n+            if service in ('git-upload-pack', 'git-receive-pack'):\n+                return await handle_info_refs(service)\n+            else:\n+                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n+        elif request.method == 'POST' and '/git-upload-pack' in path:\n+            return await handle_service_rpc('git-upload-pack')\n+        elif request.method == 'POST' and '/git-receive-pack' in path:\n+            return await handle_service_rpc('git-receive-pack')\n+        return web.Response(text=\"Not found\", status=404)\n+\n+if __name__ == '__main__':\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+        logger.info(\"Using uvloop for improved performance\")\n+    except ImportError:\n+        logger.info(\"uvloop not available, using standard event loop\")\n+    app = GitApplication()\n+    logger.info(\"Starting Git server on port 8080\")\n+    web.run_app(app, port=8080)\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 57e90a3..eed888a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,67 +1,144 @@\n-_C='delete'\n-_B='set'\n-_A='get'\n-import functools,json\n+import functools\n+import json\n+\n from snek.system import security\n-cache=functools.cache\n-CACHE_MAX_ITEMS_DEFAULT=5000\n+\n+cache = functools.cache\n+\n+CACHE_MAX_ITEMS_DEFAULT = 5000\n+\n+\n class Cache:\n-\tdef __init__(A,app,max_items=CACHE_MAX_ITEMS_DEFAULT):A.app=app;A.cache={};A.max_items=max_items;A.stats={};A.lru=[];A.version=15505\n-\tasync def get(A,args):\n-\t\tB=args;await A.update_stat(B,_A)\n-\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\texcept:return\n-\t\tA.lru.insert(0,B)\n-\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n-\t\treturn A.cache[B]\n-\tasync def get_stats(A):\n-\t\tC=[]\n-\t\tfor B in A.lru:C.append({'key':B,_B:A.stats[B][_B],_A:A.stats[B][_A],_C:A.stats[B][_C],'value':str(A.serialize(A.cache[B].record))})\n-\t\treturn C\n-\tdef serialize(C,obj):B=None;A=obj.copy();A.pop('created_at',B);A.pop('deleted_at',B);A.pop('email',B);A.pop('password',B);return A\n-\tasync def update_stat(A,key,action):\n-\t\tC=action;B=key\n-\t\tif B not in A.stats:A.stats[B]={_B:0,_A:0,_C:0}\n-\t\tA.stats[B][C]=A.stats[B][C]+1\n-\tdef json_default(B,value):\n-\t\tA=value\n-\t\ttry:return json.dumps(A.__dict__,default=str)\n-\t\texcept:return str(A)\n-\tasync def create_cache_key(A,args,kwargs):return await security.hash(json.dumps({'args':args,'kwargs':kwargs},sort_keys=True,default=A.json_default))\n-\tasync def set(A,args,result):\n-\t\tB=args;C=B not in A.cache;A.cache[B]=result;await A.update_stat(B,_B)\n-\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\texcept(ValueError,IndexError):pass\n-\t\tA.lru.insert(0,B)\n-\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n-\t\tif C:A.version+=1\n-\tasync def delete(A,args):\n-\t\tB=args;await A.update_stat(B,_C)\n-\t\tif B in A.cache:\n-\t\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\t\texcept IndexError:pass\n-\t\t\tdel A.cache[B]\n-\tdef async_cache(A,func):\n-\t\t@functools.wraps(func)\n-\t\tasync def B(*B,**C):\n-\t\t\tD=await A.create_cache_key(B,C);E=await A.get(D)\n-\t\t\tif E:return E\n-\t\t\tF=await func(*B,**C);await A.set(D,F);return F\n-\t\treturn B\n-\tdef async_delete_cache(A,func):\n-\t\t@functools.wraps(func)\n-\t\tasync def B(*C,**D):\n-\t\t\tB=await A.create_cache_key(C,D)\n-\t\t\tif B in A.cache:\n-\t\t\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\t\t\texcept IndexError:pass\n-\t\t\t\tdel A.cache[B]\n-\t\t\treturn await func(*C,**D)\n-\t\treturn B\n+    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n+        self.app = app\n+        self.cache = {}\n+        self.max_items = max_items\n+        self.stats = {}\n+        self.lru = []\n+        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n+\n+    async def get(self, args):\n+        await self.update_stat(args, \"get\")\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except:\n+            return None\n+        self.lru.insert(0, args)\n+        while len(self.lru) > self.max_items:\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+        return self.cache[args]\n+\n+    async def get_stats(self):\n+        all_ = []\n+        for key in self.lru:\n+            all_.append(\n+                {\n+                    \"key\": key,\n+                    \"set\": self.stats[key][\"set\"],\n+                    \"get\": self.stats[key][\"get\"],\n+                    \"delete\": self.stats[key][\"delete\"],\n+                    \"value\": str(self.serialize(self.cache[key].record)),\n+                }\n+            )\n+        return all_\n+\n+    def serialize(self, obj):\n+        cpy = obj.copy()\n+        cpy.pop(\"created_at\", None)\n+        cpy.pop(\"deleted_at\", None)\n+        cpy.pop(\"email\", None)\n+        cpy.pop(\"password\", None)\n+        return cpy\n+\n+    async def update_stat(self, key, action):\n+        if key not in self.stats:\n+            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n+        self.stats[key][action] = self.stats[key][action] + 1\n+\n+    def json_default(self, value):\n+        try:\n+            return json.dumps(value.__dict__, default=str)\n+        except:\n+            return str(value)\n+\n+    async def create_cache_key(self, args, kwargs):\n+        return await security.hash(\n+            json.dumps(\n+                {\"args\": args, \"kwargs\": kwargs},\n+                sort_keys=True,\n+                default=self.json_default,\n+            )\n+        )\n+\n+    async def set(self, args, result):\n+        is_new = args not in self.cache\n+        self.cache[args] = result\n+        await self.update_stat(args, \"set\")\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except (ValueError, IndexError):\n+            pass\n+        self.lru.insert(0, args)\n+\n+        while len(self.lru) > self.max_items:\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+\n+        if is_new:\n+            self.version += 1\n+\n+    async def delete(self, args):\n+        await self.update_stat(args, \"delete\")\n+        if args in self.cache:\n+            try:\n+                self.lru.pop(self.lru.index(args))\n+            except IndexError:\n+                pass\n+            del self.cache[args]\n+\n+    def async_cache(self, func):\n+        @functools.wraps(func)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n+            cached = await self.get(cache_key)\n+            if cached:\n+                return cached\n+            result = await func(*args, **kwargs)\n+            await self.set(cache_key, result)\n+            return result\n+\n+        return wrapper\n+\n+    def async_delete_cache(self, func):\n+        @functools.wraps(func)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n+            if cache_key in self.cache:\n+                try:\n+                    self.lru.pop(self.lru.index(cache_key))\n+                except IndexError:\n+                    pass\n+                del self.cache[cache_key]\n+            return await func(*args, **kwargs)\n+\n+        return wrapper\n+\n+\n def async_cache(func):\n-\tB={}\n-\t@functools.wraps(func)\n-\tasync def A(*A):\n-\t\tif A in B:return B[A]\n-\t\tC=await func(*A);B[A]=C;return C\n-\treturn A\n\\ No newline at end of file\n+    cache = {}\n+\n+    @functools.wraps(func)\n+    async def wrapper(*args):\n+        if args in cache:\n+            return cache[args]\n+        result = await func(*args)\n+        cache[args] = result\n+        return result\n+\n+    return wrapper\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 0ec782b..f4cf2d3 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,32 +1,120 @@\n-_B='fields'\n-_A=None\n+\n+\n+\n+\n from snek.system import model\n+\n+\n class HTMLElement(model.ModelField):\n-\tdef __init__(A,id=_A,tag='div',name=_A,html=_A,class_name=_A,text=_A,*B,**C):A.tag=tag;A.text=text;A.id=id;A.class_name=class_name or name;A.html=html;super().__init__(*B,name=name,**C)\n-\tasync def to_json(B):A=await super().to_json();A['text']=B.text;A['id']=B.id;A['html']=B.html;A['class_name']=B.class_name;A['tag']=B.tag;return A\n-class FormElement(HTMLElement):0\n+    def __init__(\n+        self,\n+        id=None,\n+        tag=\"div\",\n+        name=None,\n+        html=None,\n+        class_name=None,\n+        text=None,\n+        *args,\n+        **kwargs,\n+    ):\n+        self.tag = tag\n+        self.text = text\n+        self.id = id\n+        self.class_name = class_name or name\n+        self.html = html\n+        super().__init__(name=name, *args, **kwargs)\n+\n+    async def to_json(self):\n+        result = await super().to_json()\n+        result[\"text\"] = self.text\n+        result[\"id\"] = self.id\n+        result[\"html\"] = self.html\n+        result[\"class_name\"] = self.class_name\n+        result[\"tag\"] = self.tag\n+        return result\n+\n+\n+class FormElement(HTMLElement):\n+    pass\n+\n+\n class FormInputElement(FormElement):\n-\tdef __init__(A,type='text',place_holder=_A,*B,**C):super().__init__(*B,tag='input',**C);A.place_holder=place_holder;A.type=type\n-\tasync def to_json(B):A=await super().to_json();A['place_holder']=B.place_holder;A['type']=B.type;return A\n+    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n+        super().__init__(tag=\"input\", *args, **kwargs)\n+        self.place_holder = place_holder\n+        self.type = type\n+\n+    async def to_json(self):\n+        data = await super().to_json()\n+        data[\"place_holder\"] = self.place_holder\n+        data[\"type\"] = self.type\n+        return data\n+\n+\n class FormButtonElement(FormElement):\n-\tdef __init__(C,tag='button',*A,**B):super().__init__(*A,tag=tag,**B)\n+    def __init__(self, tag=\"button\", *args, **kwargs):\n+        super().__init__(tag=tag, *args, **kwargs)\n+\n+\n class Form(model.BaseModel):\n-\t@property\n-\tdef html_elements(self):return[A for A in self.fields if isinstance(A,HTMLElement)]\n-\tdef set_user_data(A,data):return super().set_user_data(data.get(_B))\n-\tasync def to_json(D,encode=False):\n-\t\tB='is_valid';E=await super().to_json();C={}\n-\t\tfor A in E.keys():\n-\t\t\tif A==B:continue\n-\t\t\tF=getattr(D,A)\n-\t\t\tif isinstance(F,HTMLElement):\n-\t\t\t\ttry:C[A]=E[A]\n-\t\t\t\texcept KeyError:pass\n-\t\tG=all(A[B]for A in C.values());return{_B:C,B:G,'errors':await D.errors}\n-\t@property\n-\tasync def errors(self):\n-\t\tA=[]\n-\t\tfor B in self.html_elements:A+=await B.errors\n-\t\treturn A\n-\t@property\n-\tasync def is_valid(self):return False\n\\ No newline at end of file\n+    @property\n+    def html_elements(self):\n+        return [element for element in self.fields if isinstance(element, HTMLElement)]\n+\n+    def set_user_data(self, data):\n+        return super().set_user_data(data.get(\"fields\"))\n+\n+    async def to_json(self, encode=False):\n+        elements = await super().to_json()\n+        html_elements = {}\n+        for element in elements.keys():\n+            if element == \"is_valid\":\n+                continue\n+            field = getattr(self, element)\n+            if isinstance(field, HTMLElement):\n+                try:\n+                    html_elements[element] = elements[element]\n+                except KeyError:\n+                    pass\n+\n+        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n+        return {\n+            \"fields\": html_elements,\n+            \"is_valid\": is_valid,\n+            \"errors\": await self.errors,\n+        }\n+\n+    @property\n+    async def errors(self):\n+        result = []\n+        for field in self.html_elements:\n+            result += await field.errors\n+        return result\n+\n+    @property\n+    async def is_valid(self):\n+        return False\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex fa993d5..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,44 +1,110 @@\n-import asyncio,pathlib,uuid,zlib\n+\n+\n+\n+\n+\n+import asyncio\n+import pathlib\n+import uuid\n+import zlib\n from urllib.parse import urljoin\n-import aiohttp,imgkit\n+\n+import aiohttp\n+import imgkit\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n+\n+\n async def crc32(data):\n-\tA=data\n-\ttry:A=A.encode()\n-\texcept:pass\n-\treturn'crc32'+str(zlib.crc32(A))\n-async def get_file(name,suffix='.cache'):\n-\tA=name;A=await crc32(A);B=pathlib.Path('.').joinpath('cache')\n-\tif not B.exists():B.mkdir(parents=True,exist_ok=True)\n-\treturn B.joinpath(A+suffix)\n-async def public_touch(name=None):A=pathlib.Path('.').joinpath(str(uuid.uuid4())+name);A.open('wb').close();return A\n+    try:\n+        data = data.encode()\n+    except:\n+        pass\n+    return \"crc32\" + str(zlib.crc32(data))\n+\n+\n+async def get_file(name, suffix=\".cache\"):\n+    name = await crc32(name)\n+    path = pathlib.Path(\".\").joinpath(\"cache\")\n+    if not path.exists():\n+        path.mkdir(parents=True, exist_ok=True)\n+    return path.joinpath(name + suffix)\n+\n+\n+async def public_touch(name=None):\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n+    path.open(\"wb\").close()\n+    return path\n+\n+\n async def create_site_photo(url):\n-\tA=url;C=asyncio.get_event_loop()\n-\tB=await get_file('site-screenshot-'+A,'.png')\n-\tif B.exists():return B\n-\tB.touch()\n-\tdef D():imgkit.from_url(A,B.absolute());return B\n-\treturn await C.run_in_executor(None,D)\n-async def repair_links(base_url,html_content):\n-\tD='http';E=base_url;B='src';C='href';F=BeautifulSoup(html_content,'html.parser')\n-\tfor A in F.find_all(['a','img','link']):\n-\t\tif A.has_attr(C)and not A[C].startswith(D):A[C]=urljoin(E,A[C])\n-\t\tif A.has_attr(B)and not A[B].startswith(D):A[B]=urljoin(E,A[B])\n-\treturn F.prettify()\n-async def is_html_content(content):\n-\tB=False;A=content\n-\tif not A:return B\n-\ttry:A=A.decode(errors='ignore')\n-\texcept:pass\n-\tC=['<html','<img','<p','<span','<div'];A=A.lower()\n-\tfor D in C:\n-\t\tif D in A:return True\n-\treturn B\n+    loop = asyncio.get_event_loop()\n+    if not url.startswith(\"https\"):\n+    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n+\n+    if output_path.exists():\n+        return output_path\n+    output_path.touch()\n+\n+    def make_photo():\n+        imgkit.from_url(url, output_path.absolute())\n+        return output_path\n+\n+    return await loop.run_in_executor(None, make_photo)\n+\n+\n+async def repair_links(base_url, html_content):\n+    soup = BeautifulSoup(html_content, \"html.parser\")\n+    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n+        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n+            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n+        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n+            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n+    return soup.prettify()\n+\n+\n+async def is_html_content(content: bytes):\n+    if not content:\n+        return False\n+    try:\n+        content = content.decode(errors=\"ignore\")\n+    except:\n+        pass\n+    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n+    content = content.lower()\n+    for mark in marks:\n+        if mark in content:\n+            return True\n+    return False\n+\n+\n @time_cache_async(120)\n async def get(url):\n-\tasync with aiohttp.ClientSession()as B:\n-\t\tC=await B.get(url);A=await C.text()\n-\t\tif await is_html_content(A):A=(await repair_links(url,A)).encode()\n-\t\treturn A\n\\ No newline at end of file\n+    async with aiohttp.ClientSession() as session:\n+        response = await session.get(url)\n+        content = await response.text()\n+        if await is_html_content(content):\n+            content = (await repair_links(url, content)).encode()\n+        return content\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex beb313e..4a59024 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,37 +1,70 @@\n-_A='uid'\n-DEFAULT_LIMIT=30\n+DEFAULT_LIMIT = 30\n import typing\n+\n from snek.system.model import BaseModel\n+\n+\n class BaseMapper:\n-\tmodel_class:BaseModel=None;default_limit:int=DEFAULT_LIMIT;table_name:str=None\n-\tdef __init__(A,app):A.app=app;A.default_limit=A.__class__.default_limit\n-\t@property\n-\tdef db(self):return self.app.db\n-\tasync def new(A):return A.model_class(mapper=A,app=A.app)\n-\t@property\n-\tdef table(self):return self.db[self.table_name]\n-\tasync def get(B,uid=None,**C):\n-\t\tif uid:C[_A]=uid\n-\t\tA=B.table.find_one(**C)\n-\t\tif not A:return\n-\t\tA=dict(A);D=await B.new()\n-\t\tfor(E,F)in A.items():D[E]=F\n-\t\treturn D;return await B.model_class.from_record(mapper=B,record=A)\n-\tasync def exists(A,**B):return A.table.exists(**B)\n-\tasync def count(A,**B):return A.table.count(**B)\n-\tasync def save(B,model):\n-\t\tA=model\n-\t\tif not A.record.get(_A):raise Exception(f\"Attempt to save without uid: {A.record}.\")\n-\t\tA.updated_at.update();return B.table.upsert(A.record,[_A])\n-\tasync def find(A,**B):\n-\t\tC='_limit'\n-\t\tif not B.get(C):B[C]=A.default_limit\n-\t\tfor E in A.table.find(**B):\n-\t\t\tD=await A.new()\n-\t\t\tfor(F,G)in E.items():D[F]=G\n-\t\t\tyield D\n-\tasync def query(A,sql,*B):\n-\t\tfor C in A.db.query(sql,*B):yield dict(C)\n-\tasync def delete(B,**A):\n-\t\tif not A or not isinstance(A,dict):raise Exception(\"Can't execute delete with no filter.\")\n-\t\treturn B.table.delete(**A)\n\\ No newline at end of file\n+\n+    model_class: BaseModel = None\n+    default_limit: int = DEFAULT_LIMIT\n+    table_name: str = None\n+\n+    def __init__(self, app):\n+        self.app = app\n+\n+        self.default_limit = self.__class__.default_limit\n+\n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    async def new(self):\n+        return self.model_class(mapper=self, app=self.app)\n+\n+    @property\n+    def table(self):\n+        return self.db[self.table_name]\n+\n+    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n+        if uid:\n+            kwargs[\"uid\"] = uid\n+        record = self.table.find_one(**kwargs)\n+        if not record:\n+            return None\n+        record = dict(record)\n+        model = await self.new()\n+        for key, value in record.items():\n+            model[key] = value\n+        return model\n+        return await self.model_class.from_record(mapper=self, record=record)\n+\n+    async def exists(self, **kwargs):\n+        return self.table.exists(**kwargs)\n+\n+    async def count(self, **kwargs) -> int:\n+        return self.table.count(**kwargs)\n+\n+    async def save(self, model: BaseModel) -> bool:\n+        if not model.record.get(\"uid\"):\n+            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        model.updated_at.update()\n+        return self.table.upsert(model.record, [\"uid\"])\n+\n+    async def find(self, **kwargs) -> typing.AsyncGenerator:\n+        if not kwargs.get(\"_limit\"):\n+            kwargs[\"_limit\"] = self.default_limit\n+        for record in self.table.find(**kwargs):\n+            model = await self.new()\n+            for key, value in record.items():\n+                model[key] = value\n+            yield model\n+\n+    async def query(self, sql, *args):\n+        for record in self.db.query(sql, *args):\n+            yield dict(record)\n+\n+    async def delete(self, **kwargs) -> int:\n+        if not kwargs or not isinstance(kwargs, dict):\n+            raise Exception(\"Can't execute delete with no filter.\")\n+        return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex b530fb8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,35 +1,87 @@\n-_A=True\n+\n from types import SimpleNamespace\n+\n from app.cache import time_cache_async\n-from mistune import HTMLRenderer,Markdown\n+from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n+\n+\n class MarkdownRenderer(HTMLRenderer):\n-\t_allow_harmful_protocols=_A\n-\tdef __init__(A,app,template):A.template=template;A.app=app;A.env=A.app.jinja2_env;B=html.HtmlFormatter();A.env.globals['highlight_styles']=B.get_style_defs()\n-\tdef _escape(A,str):return str\n-\tdef get_lexer(A,lang,default='bash'):\n-\t\ttry:return get_lexer_by_name(lang,stripall=_A)\n-\t\texcept:return get_lexer_by_name(default,stripall=_A)\n-\tdef block_code(B,code,lang=None,info=None):\n-\t\tA=lang\n-\t\tif not A:A=info\n-\t\tif not A:A='bash'\n-\t\tC=B.get_lexer(A);D=html.HtmlFormatter(lineseparator='<br>');E=highlight(code,C,D);return E\n-\tdef render(A):B=A.app.template_path.joinpath(A.template).read_text();C=MarkdownRenderer(A.app,A.template);D=Markdown(renderer=C);return D(B)\n-def render_markdown_sync(app,markdown_string):A=MarkdownRenderer(app,None);B=Markdown(renderer=A);return B(markdown_string)\n+\n+    _allow_harmful_protocols = True\n+\n+    def __init__(self, app, template):\n+        self.template = template\n+\n+        self.app = app\n+        self.env = self.app.jinja2_env\n+        formatter = html.HtmlFormatter()\n+        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n+\n+    def _escape(self, str):\n+\n+    def get_lexer(self, lang, default=\"bash\"):\n+        try:\n+            return get_lexer_by_name(lang, stripall=True)\n+        except:\n+            return get_lexer_by_name(default, stripall=True)\n+\n+    def block_code(self, code, lang=None, info=None):\n+        if not lang:\n+            lang = info\n+        if not lang:\n+            lang = \"bash\"\n+        lexer = self.get_lexer(lang)\n+        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n+        result = highlight(code, lexer, formatter)\n+        return result\n+\n+    def render(self):\n+        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n+        renderer = MarkdownRenderer(self.app, self.template)\n+        markdown = Markdown(renderer=renderer)\n+        return markdown(markdown_string)\n+\n+\n+def render_markdown_sync(app, markdown_string):\n+    renderer = MarkdownRenderer(app, None)\n+    markdown = Markdown(renderer=renderer)\n+    return markdown(markdown_string)\n+\n+\n @time_cache_async(120)\n-async def render_markdown(app,markdown_string):return render_markdown_sync(app,markdown_string)\n-from jinja2 import TemplateSyntaxError,nodes\n+async def render_markdown(app, markdown_string):\n+    return render_markdown_sync(app, markdown_string)\n+\n+\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n+\n+\n class MarkdownExtension(Extension):\n-\ttags={'markdown'}\n-\tdef __init__(A,environment):B=environment;A.app=SimpleNamespace(jinja2_env=B);super(MarkdownExtension,A).__init__(B)\n-\tdef parse(D,parser):\n-\t\tA=parser;E=next(A.stream).lineno;B=[Const('')];C=''\n-\t\ttry:B=[A.parse_expression()]\n-\t\texcept TemplateSyntaxError:C=A.parse_statements(['name:endmarkdown'],drop_needle=_A)\n-\t\treturn nodes.CallBlock(D.call_method('_to_html',B),[],[],C).set_lineno(E)\n-\tdef _to_html(A,md_file,caller):return render_markdown_sync(A.app,caller())\n\\ No newline at end of file\n+    tags = {\"markdown\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(MarkdownExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return render_markdown_sync(self.app, caller())\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 1437a3f..3a9a055 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,21 +1,53 @@\n-_D='Access-Control-Allow-Credentials'\n-_C='Access-Control-Allow-Headers'\n-_B='Access-Control-Allow-Methods'\n-_A='Access-Control-Allow-Origin'\n+\n+\n+\n+\n from aiohttp import web\n+\n+\n @web.middleware\n-async def no_cors_middleware(request,handler):A=await handler(request);A.headers.pop(_A,None);return A\n+async def no_cors_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n+    return response\n+\n+\n @web.middleware\n-async def cors_allow_middleware(request,handler):A=await handler(request);A.headers[_A]='*';A.headers[_B]='GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND';A.headers[_C]='*';A.headers[_D]='true';return A\n+async def cors_allow_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = (\n+        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n+    )\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n+    return response\n+\n+\n @web.middleware\n-async def auth_middleware(request,handler):\n-\tB='uid';C='user';A=request;A[C]=None\n-\tif A.session.get(B)and A.session.get('logged_in'):A[C]=await A.app.services.user.get(uid=A.app.session.get(B))\n-\treturn await handler(A)\n+async def auth_middleware(request, handler):\n+    request[\"user\"] = None\n+    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n+        request[\"user\"] = await request.app.services.user.get(\n+            uid=request.app.session.get(\"uid\")\n+        )\n+    return await handler(request)\n+\n+\n @web.middleware\n-async def cors_middleware(request,handler):\n-\tC='Allow';D=handler;B=request\n-\tif B.headers.get(C):return await D(B)\n-\tA=await D(B)\n-\tif B.headers.get(C):return A\n-\tA.headers[_A]='*';A.headers[_B]='GET, POST, PUT, DELETE, OPTIONS';A.headers[_C]='*';A.headers[_D]='true';return A\n\\ No newline at end of file\n+async def cors_middleware(request, handler):\n+    if request.headers.get(\"Allow\"):\n+        return await handler(request)\n+\n+    response = await handler(request)\n+    if request.headers.get(\"Allow\"):\n+        return response\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n+    return response\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex b036329..9e9830d 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,139 +1,377 @@\n-_I='deleted_at'\n-_H='updated_at'\n-_G='created_at'\n-_F='is_valid'\n-_E='name'\n-_D=False\n-_C='value'\n-_B=True\n-_A=None\n-import copy,json,re,uuid\n+\n+\n+\n+\n+\n+import copy\n+import json\n+import re\n+import uuid\n from collections import OrderedDict\n-from datetime import datetime,timezone\n-TIMESTAMP_REGEX='^\\\\d{4}-\\\\d{2}-\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{6}\\\\+\\\\d{2}:\\\\d{2}$'\n-def now():return str(datetime.now(timezone.utc))\n-def add_attrs(**A):\n-\tdef B(func):\n-\t\tfor(B,C)in A.items():setattr(func,B,C)\n-\t\treturn func\n-\treturn B\n-def validate_attrs(required=_D,min_length=_A,max_length=_A,regex=_A,**A):\n-\tdef B(func):return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**A)(func)\n+from datetime import datetime, timezone\n+\n+TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n+\n+\n+def now():\n+    return str(datetime.now(timezone.utc))\n+\n+\n+def add_attrs(**kwargs):\n+    def decorator(func):\n+        for key, value in kwargs.items():\n+            setattr(func, key, value)\n+        return func\n+\n+    return decorator\n+\n+\n+def validate_attrs(\n+    required=False, min_length=None, max_length=None, regex=None, **kwargs\n+):\n+    def decorator(func):\n+        return add_attrs(\n+            required=required,\n+            min_length=min_length,\n+            max_length=max_length,\n+            regex=regex,\n+            **kwargs,\n+        )(func)\n+\n+\n class Validator:\n-\t_index=0\n-\t@property\n-\tdef value(self):return self._value\n-\t@value.setter\n-\tdef value(self,val):self._value=json.loads(json.dumps(val,default=str))\n-\t@property\n-\tdef initial_value(self):return self.value\n-\tdef custom_validation(A):return _B\n-\tdef __init__(A,required=_D,min_num=_A,max_num=_A,min_length=_A,max_length=_A,regex=_A,value=_A,kind=_A,help_text=_A,app=_A,model=_A,**B):A.index=Validator._index;Validator._index+=1;A.app=app;A.model=model;A.required=required;A.min_num=min_num;A.max_num=max_num;A.min_length=min_length;A.max_length=max_length;A.regex=regex;A._value=_A;A.value=value;A.kind=kind;A.help_text=help_text;A.__dict__.update(B)\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=[]\n-\t\tif A.value is _A and A.required:B.append('Field is required.');return B\n-\t\tif A.value is _A:return B\n-\t\tif A.kind in[int,float]:\n-\t\t\tif A.min_num is not _A and A.value<A.min_num:B.append(f\"Field should be minimal {A.min_num}.\")\n-\t\t\tif A.max_num is not _A and A.value>A.max_num:B.append(f\"Field should be maximal {A.max_num}.\")\n-\t\tif A.min_length is not _A and len(A.value)<A.min_length:B.append(f\"Field should be minimal {A.min_length} characters long.\")\n-\t\tif A.max_length is not _A and len(A.value)>A.max_length:B.append(f\"Field should be maximal {A.max_length} characters long.\")\n-\t\tif A.regex and A.value and not re.match(A.regex,A.value):B.append('Invalid value.')\n-\t\tif A.kind and not isinstance(A.value,A.kind):B.append(f\"Invalid kind. It is supposed to be {A.kind}.\")\n-\t\treturn B\n-\tasync def validate(B):\n-\t\tA=await B.errors\n-\t\tif A:raise ValueError(f\"Errors: {A}.\")\n-\t\treturn _B\n-\tdef __repr__(A):return str(A.to_json())\n-\t@property\n-\tasync def is_valid(self):\n-\t\ttry:await self.validate();return _B\n-\t\texcept ValueError:return _D\n-\tasync def to_json(A):B=await A.errors;C=await A.is_valid;return{'required':A.required,'min_num':A.min_num,'max_num':A.max_num,'min_length':A.min_length,'max_length':A.max_length,'regex':A.regex,_C:A.value,'kind':str(A.kind),'help_text':A.help_text,'errors':B,_F:C,'index':A.index}\n+    _index = 0\n+\n+    @property\n+    def value(self):\n+        return self._value\n+\n+    @value.setter\n+    def value(self, val):\n+        self._value = json.loads(json.dumps(val, default=str))\n+\n+    @property\n+    def initial_value(self):\n+        return self.value\n+\n+    def custom_validation(self):\n+        return True\n+\n+    def __init__(\n+        self,\n+        required=False,\n+        min_num=None,\n+        max_num=None,\n+        min_length=None,\n+        max_length=None,\n+        regex=None,\n+        value=None,\n+        kind=None,\n+        help_text=None,\n+        app=None,\n+        model=None,\n+        **kwargs,\n+    ):\n+        self.index = Validator._index\n+        Validator._index += 1\n+        self.app = app\n+        self.model = model\n+        self.required = required\n+        self.min_num = min_num\n+        self.max_num = max_num\n+        self.min_length = min_length\n+        self.max_length = max_length\n+        self.regex = regex\n+        self._value = None\n+        self.value = value\n+        self.kind = kind\n+        self.help_text = help_text\n+        self.__dict__.update(kwargs)\n+\n+    @property\n+    async def errors(self):\n+        error_list = []\n+        if self.value is None and self.required:\n+            error_list.append(\"Field is required.\")\n+            return error_list\n+\n+        if self.value is None:\n+            return error_list\n+\n+        if self.kind in [int, float]:\n+            if self.min_num is not None and self.value < self.min_num:\n+                error_list.append(f\"Field should be minimal {self.min_num}.\")\n+            if self.max_num is not None and self.value > self.max_num:\n+                error_list.append(f\"Field should be maximal {self.max_num}.\")\n+        if self.min_length is not None and len(self.value) < self.min_length:\n+            error_list.append(\n+                f\"Field should be minimal {self.min_length} characters long.\"\n+            )\n+        if self.max_length is not None and len(self.value) > self.max_length:\n+            error_list.append(\n+                f\"Field should be maximal {self.max_length} characters long.\"\n+            )\n+        if self.regex and self.value and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\")\n+        if self.kind and not isinstance(self.value, self.kind):\n+            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n+        return error_list\n+\n+    async def validate(self):\n+        errors = await self.errors\n+        if errors:\n+            raise ValueError(f\"Errors: {errors}.\")\n+        return True\n+\n+    def __repr__(self):\n+        return str(self.to_json())\n+\n+    @property\n+    async def is_valid(self):\n+        try:\n+            await self.validate()\n+            return True\n+        except ValueError:\n+            return False\n+\n+    async def to_json(self):\n+        errors = await self.errors\n+        is_valid = await self.is_valid\n+        return {\n+            \"required\": self.required,\n+            \"min_num\": self.min_num,\n+            \"max_num\": self.max_num,\n+            \"min_length\": self.min_length,\n+            \"max_length\": self.max_length,\n+            \"regex\": self.regex,\n+            \"value\": self.value,\n+            \"kind\": str(self.kind),\n+            \"help_text\": self.help_text,\n+            \"errors\": errors,\n+            \"is_valid\": is_valid,\n+            \"index\": self.index,\n+        }\n+\n+\n class ModelField(Validator):\n-\tindex=1\n-\tdef __init__(A,name=_A,save=_B,*B,**C):A.name=name;A.save=save;super().__init__(*B,**C)\n-\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;return A\n+\n+    index = 1\n+\n+    def __init__(self, name=None, save=True, *args, **kwargs):\n+        self.name = name\n+        self.save = save\n+        super().__init__(*args, **kwargs)\n+\n+    async def to_json(self):\n+        result = await super().to_json()\n+        result[\"name\"] = self.name\n+        return result\n+\n+\n class CreatedField(ModelField):\n-\t@property\n-\tdef initial_value(self):return now()\n-\tdef update(A):\n-\t\tif not A.value:A.value=now()\n+\n+    @property\n+    def initial_value(self):\n+        return now()\n+\n+    def update(self):\n+        if not self.value:\n+            self.value = now()\n+\n+\n class UpdatedField(ModelField):\n-\tdef update(A):A.value=now()\n+\n+    def update(self):\n+        self.value = now()\n+\n+\n class DeletedField(ModelField):\n-\tdef update(A):A.value=now()\n+\n+    def update(self):\n+        self.value = now()\n+\n+\n class UUIDField(ModelField):\n-\t@property\n-\tdef value(self):return str(self._value)\n-\t@value.setter\n-\tdef value(self,val):self._value=str(val)\n-\t@property\n-\tdef initial_value(self):return str(uuid.uuid4())\n+\n+    @property\n+    def value(self):\n+        return str(self._value)\n+\n+    @value.setter\n+    def value(self, val):\n+        self._value = str(val)\n+\n+    @property\n+    def initial_value(self):\n+        return str(uuid.uuid4())\n+\n+\n class BaseModel:\n-\tuid=UUIDField(name='uid',required=_B);created_at=CreatedField(name=_G,required=_B,regex=TIMESTAMP_REGEX,place_holder='Created at');updated_at=UpdatedField(name=_H,regex=TIMESTAMP_REGEX,place_holder='Updated at');deleted_at=DeletedField(name=_I,regex=TIMESTAMP_REGEX,place_holder='Deleted at')\n-\t@classmethod\n-\tasync def from_record(B,record,mapper):A=B();A.mapper=mapper;A.record=record;return A\n-\t@property\n-\tdef mapper(self):return self._mapper\n-\t@mapper.setter\n-\tdef mapper(self,value):self._mapper=value\n-\t@property\n-\tdef record(self):return{A:B.value for(A,B)in self.fields.items()}\n-\t@record.setter\n-\tdef record(self,val):\n-\t\tA=self\n-\t\tfor(B,C)in val.items():\n-\t\t\tD=A.fields.get(B)\n-\t\t\tif not D:continue\n-\t\t\tA[B]=C\n-\t\treturn A\n-\tdef __init__(A,*F,**C):\n-\t\tD='app';A._mapper=C.get('mapper');A.app=C.get(D);A.fields={}\n-\t\tfor B in dir(A.__class__):\n-\t\t\tE=getattr(A.__class__,B)\n-\t\t\tif isinstance(E,Validator):A.__dict__[B]=copy.deepcopy(E);A.__dict__[B].value=C.pop(B,A.__dict__[B].initial_value);A.fields[B]=A.__dict__[B];A.fields[B].model=A;A.fields[B].app=C.get(D)\n-\tdef __setitem__(B,key,value):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):A.value=value\n-\tdef __getattr__(B,key):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):return A.value\n-\t\treturn A\n-\tdef set_user_data(C,data):\n-\t\tfor(D,A)in data.items():\n-\t\t\tB=C.fields.get(D)\n-\t\t\tif not B:continue\n-\t\t\tif A.get(_E):A=A.get(_C)\n-\t\t\tB.value=A\n-\t@property\n-\tasync def is_valid(self):return all([await A.is_valid for A in self.fields.values()])\n-\tdef __getitem__(B,key):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):return A.value\n-\tdef __setattr__(A,key,value):\n-\t\tB=value;C=getattr(A,key)\n-\t\tif isinstance(C,Validator):C.value=B\n-\t\telse:A.__dict__[key]=B\n-\t@property\n-\tasync def recordz(self):\n-\t\tD=await self.to_json();B={}\n-\t\tfor(C,A)in D.items():\n-\t\t\tif not isinstance(A,dict)or _C not in A:continue\n-\t\t\tif getattr(self,C).save:B[C]=A.get(_C)\n-\t\treturn B\n-\tasync def to_json(A,encode=_D):\n-\t\tB=OrderedDict({'uid':A.uid.value,_G:A.created_at.value,_H:A.updated_at.value,_I:A.deleted_at.value,_F:await A.is_valid})\n-\t\tfor(C,D)in A.fields.items():\n-\t\t\tif C=='record':continue\n-\t\t\tD=A.__dict__[C]\n-\t\t\tif hasattr(D,_C):B[C]=await D.to_json()\n-\t\tif encode:return json.dumps(B,indent=2)\n-\t\treturn B\n+\n+    uid = UUIDField(name=\"uid\", required=True)\n+    created_at = CreatedField(\n+        name=\"created_at\",\n+        required=True,\n+        regex=TIMESTAMP_REGEX,\n+        place_holder=\"Created at\",\n+    )\n+    updated_at = UpdatedField(\n+        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n+    )\n+    deleted_at = DeletedField(\n+        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n+    )\n+\n+    @classmethod\n+    async def from_record(cls, record, mapper):\n+        model = cls()\n+        model.mapper = mapper\n+        model.record = record\n+        return model\n+\n+    @property\n+    def mapper(self):\n+        return self._mapper\n+\n+    @mapper.setter\n+    def mapper(self, value):\n+        self._mapper = value\n+\n+    @property\n+    def record(self):\n+        return {key: field.value for key, field in self.fields.items()}\n+\n+    @record.setter\n+    def record(self, val):\n+        for key, value in val.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            self[key] = value\n+        return self\n+\n+    def __init__(self, *args, **kwargs):\n+        self._mapper = kwargs.get(\"mapper\")\n+        self.app = kwargs.get(\"app\")\n+        self.fields = {}\n+        for key in dir(self.__class__):\n+            obj = getattr(self.__class__, key)\n+\n+            if isinstance(obj, Validator):\n+                self.__dict__[key] = copy.deepcopy(obj)\n+                self.__dict__[key].value = kwargs.pop(\n+                    key, self.__dict__[key].initial_value\n+                )\n+                self.fields[key] = self.__dict__[key]\n+                self.fields[key].model = self\n+                self.fields[key].app = kwargs.get(\"app\")\n+\n+    def __setitem__(self, key, value):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            obj.value = value\n+\n+    def __getattr__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            return obj.value\n+        return obj\n+\n+    def set_user_data(self, data):\n+        for key, value in data.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            if value.get(\"name\"):\n+                value = value.get(\"value\")\n+            field.value = value\n+\n+    @property\n+    async def is_valid(self):\n+        return all([await field.is_valid for field in self.fields.values()])\n+\n+    def __getitem__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            return obj.value\n+\n+    def __setattr__(self, key, value):\n+        obj = getattr(self, key)\n+        if isinstance(obj, Validator):\n+            obj.value = value\n+        else:\n+            self.__dict__[key] = value\n+\n+    @property\n+    async def recordz(self):\n+        obj = await self.to_json()\n+        record = {}\n+        for key, value in obj.items():\n+            if not isinstance(value, dict) or \"value\" not in value:\n+                continue\n+            if getattr(self, key).save:\n+                record[key] = value.get(\"value\")\n+        return record\n+\n+    async def to_json(self, encode=False):\n+        model_data = OrderedDict(\n+            {\n+                \"uid\": self.uid.value,\n+                \"created_at\": self.created_at.value,\n+                \"updated_at\": self.updated_at.value,\n+                \"deleted_at\": self.deleted_at.value,\n+                \"is_valid\": await self.is_valid,\n+            }\n+        )\n+\n+        for key, value in self.fields.items():\n+            if key == \"record\":\n+                continue\n+            value = self.__dict__[key]\n+            if hasattr(value, \"value\"):\n+                model_data[key] = await value.to_json()\n+        if encode:\n+            return json.dumps(model_data, indent=2)\n+        return model_data\n+\n+\n class FormElement(ModelField):\n-\tdef __init__(A,place_holder=_A,*B,**C):super().__init__(*B,**C);A.place_holder=place_holder\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+\n+\n class FormElement(ModelField):\n-\tdef __init__(A,place_holder=_A,*B,**C):A.place_holder=place_holder;super().__init__(*B,**C)\n-\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;A['place_holder']=B.place_holder;return A\n\\ No newline at end of file\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        self.place_holder = place_holder\n+        super().__init__(*args, **kwargs)\n+\n+    async def to_json(self):\n+        data = await super().to_json()\n+        data[\"name\"] = self.name\n+        data[\"place_holder\"] = self.place_holder\n+        return data\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex a36bb76..f91ec42 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,7 +1,13 @@\n class Object:\n-\tdef __init__(A,*C,**D):\n-\t\tfor B in C:\n-\t\t\tif isinstance(B,dict):A.__dict__.update(B)\n-\t\tA.__dict__.update(D)\n-\tdef __getitem__(A,key):return A.__dict__[key]\n-\tdef __setitem__(A,key,value):A.__dict__[key]=value\n\\ No newline at end of file\n+\n+    def __init__(self, *args, **kwargs):\n+        for arg in args:\n+            if isinstance(arg, dict):\n+                self.__dict__.update(arg)\n+        self.__dict__.update(kwargs)\n+\n+    def __getitem__(self, key):\n+        return self.__dict__[key]\n+\n+    def __setitem__(self, key, value):\n+        self.__dict__[key] = value\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex d196b30..e0e5542 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,17 +1,46 @@\n-import cProfile,pstats,sys\n+import cProfile\n+import pstats\n+import sys\n+\n from aiohttp import web\n-profiler=None\n+\n+profiler = None\n import io\n+\n+\n @web.middleware\n-async def profile_middleware(request,handler):\n-\tglobal profiler\n-\tif not profiler:profiler=cProfile.Profile()\n-\tprofiler.enable();B=await handler(request);profiler.disable();A=pstats.Stats(profiler,stream=sys.stdout);A.sort_stats('cumulative');A.print_stats();return B\n-async def profiler_handler(request):A=io.StringIO();B=pstats.Stats(profiler,stream=A);C=request.query.get('sort','tot. percall');B.sort_stats(C);B.print_stats();return web.Response(text=A.getvalue())\n+async def profile_middleware(request, handler):\n+    global profiler\n+    if not profiler:\n+        profiler = cProfile.Profile()\n+    profiler.enable()\n+    response = await handler(request)\n+    profiler.disable()\n+    stats = pstats.Stats(profiler, stream=sys.stdout)\n+    stats.sort_stats(\"cumulative\")\n+    stats.print_stats()\n+    return response\n+\n+\n+async def profiler_handler(request):\n+    output = io.StringIO()\n+    stats = pstats.Stats(profiler, stream=output)\n+    sort_by = request.query.get(\"sort\", \"tot. percall\")\n+    stats.sort_stats(sort_by)\n+    stats.print_stats()\n+    return web.Response(text=output.getvalue())\n+\n+\n class Profiler:\n-\tdef __init__(A):\n-\t\tglobal profiler\n-\t\tif profiler is None:profiler=cProfile.Profile()\n-\t\tA.profiler=profiler\n-\tasync def __aenter__(A):A.profiler.enable()\n-\tasync def __aexit__(A,*B,**C):A.profiler.disable()\n\\ No newline at end of file\n+\n+    def __init__(self):\n+        global profiler\n+        if profiler is None:\n+            profiler = cProfile.Profile()\n+        self.profiler = profiler\n+\n+    async def __aenter__(self):\n+        self.profiler.enable()\n+\n+    async def __aexit__(self, *args, **kwargs):\n+        self.profiler.disable()\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 8d5ced9..43b61fe 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,24 +1,77 @@\n-_A='snekker-de-snek-'\n-import hashlib,uuid\n-DEFAULT_SALT=_A\n-DEFAULT_NS=_A\n+import hashlib\n+import uuid\n+\n+DEFAULT_SALT = \"snekker-de-snek-\"\n+DEFAULT_NS = \"snekker-de-snek-\"\n+\n+\n class UIDNS:\n-\tdef __init__(A,name):'Initialize UIDNS with a name.';A.name=name\n-\t@property\n-\tdef bytes(self):'Return the bytes representation of the name.';return self.name.encode()\n-def uid(value=None,ns=DEFAULT_NS):\n-\t'Generate a UUID based on the provided value and namespace.\\n\\n    Args:\\n        value (str): The value to generate the UUID from. If None, a new UUID is created.\\n        ns (str): The namespace to use for UUID generation.\\n\\n    Returns:\\n        str: The generated UUID as a string.\\n    ';A=value\n-\ttry:ns=ns.decode()\n-\texcept AttributeError:pass\n-\tif not A:A=str(uuid.uuid4())\n-\ttry:A=A.decode()\n-\texcept AttributeError:pass\n-\treturn str(uuid.uuid5(UIDNS(ns),A))\n-async def hash(data,salt=DEFAULT_SALT):\n-\t'Hash the given data with the specified salt using SHA-256.\\n\\n    Args:\\n        data (str): The data to hash.\\n        salt (str): The salt to use for hashing.\\n\\n    Returns:\\n        str: The hexadecimal representation of the hashed data.\\n    ';C='ignore';A=salt;B=data\n-\ttry:B=B.encode(errors=C)\n-\texcept AttributeError:pass\n-\ttry:A=A.encode(errors=C)\n-\texcept AttributeError:pass\n-\tD=A+B;E=hashlib.sha256(D);return E.hexdigest()\n-async def verify(string,hashed):'Verify if the given string matches the hashed value.\\n\\n    Args:\\n        string (str): The string to verify.\\n        hashed (str): The hashed value to compare against.\\n\\n    Returns:\\n        bool: True if the string matches the hashed value, False otherwise.\\n    ';return await hash(string)==hashed\n\\ No newline at end of file\n+    def __init__(self, name: str) -> None:\n+        \"\"\"Initialize UIDNS with a name.\"\"\"\n+        self.name = name\n+\n+    @property\n+    def bytes(self) -> bytes:\n+        \"\"\"Return the bytes representation of the name.\"\"\"\n+        return self.name.encode()\n+\n+\n+def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n+    \"\"\"Generate a UUID based on the provided value and namespace.\n+\n+    Args:\n+        value (str): The value to generate the UUID from. If None, a new UUID is created.\n+        ns (str): The namespace to use for UUID generation.\n+\n+    Returns:\n+        str: The generated UUID as a string.\n+    \"\"\"\n+    try:\n+        ns = ns.decode()\n+    except AttributeError:\n+        pass\n+    if not value:\n+        value = str(uuid.uuid4())\n+    try:\n+        value = value.decode()\n+    except AttributeError:\n+        pass\n+\n+    return str(uuid.uuid5(UIDNS(ns), value))\n+\n+\n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    \"\"\"Hash the given data with the specified salt using SHA-256.\n+\n+    Args:\n+        data (str): The data to hash.\n+        salt (str): The salt to use for hashing.\n+\n+    Returns:\n+        str: The hexadecimal representation of the hashed data.\n+    \"\"\"\n+    try:\n+        data = data.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    try:\n+        salt = salt.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    salted = salt + data\n+\n+    obj = hashlib.sha256(salted)\n+    return obj.hexdigest()\n+\n+\n+async def verify(string: str, hashed: str) -> bool:\n+    \"\"\"Verify if the given string matches the hashed value.\n+\n+    Args:\n+        string (str): The string to verify.\n+        hashed (str): The hashed value to compare against.\n+\n+    Returns:\n+        bool: True if the string matches the hashed value, False otherwise.\n+    \"\"\"\n+    return await hash(string) == hashed\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex eb735b1..c6d2afc 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,42 +1,67 @@\n-_B='uid'\n-_A=None\n from snek.mapper import get_mapper\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n+\n+\n class BaseService:\n-\tmapper_name:BaseMapper=_A\n-\t@property\n-\tdef services(self):return self.app.services\n-\tdef __init__(A,app):\n-\t\tA.app=app;A.cache=app.cache\n-\t\tif A.mapper_name:A.mapper=get_mapper(A.mapper_name,app=A.app)\n-\t\telse:A.mapper=_A\n-\tasync def exists(C,uid=_A,**A):\n-\t\tB=uid\n-\t\tif B:\n-\t\t\tif not A and await C.cache.get(B):return True\n-\t\t\tA[_B]=B\n-\t\treturn await C.count(**A)>0\n-\tasync def count(A,**B):return await A.mapper.count(**B)\n-\tasync def new(A,**B):return await A.mapper.new()\n-\tasync def query(A,sql,*B):\n-\t\tfor C in A.app.db.query(sql,*B):yield C\n-\tasync def get(B,uid=_A,**C):\n-\t\tD=uid\n-\t\tif D:\n-\t\t\tif not C:\n-\t\t\t\tA=await B.cache.get(D)\n-\t\t\t\tif False and A and A.__class__==B.mapper.model_class:return A\n-\t\t\tC[_B]=D\n-\t\tA=await B.mapper.get(**C)\n-\t\tif A:await B.cache.set(A[_B],A)\n-\t\treturn A\n-\tasync def save(B,model):\n-\t\tA=model\n-\t\tif await B.mapper.save(A):await B.cache.set(A[_B],A);return True\n-\t\tC=await A.errors;raise Exception(f\"Couldn't save model. Errors: f{C}\")\n-\tasync def find(C,**A):\n-\t\tB='_limit'\n-\t\tif B not in A or int(A.get(B))>30:A[B]=60\n-\t\tasync for D in C.mapper.find(**A):yield D\n-\tasync def delete(A,**B):return await A.mapper.delete(**B)\n\\ No newline at end of file\n+\n+    mapper_name: BaseMapper = None\n+\n+    @property\n+    def services(self):\n+        return self.app.services\n+\n+    def __init__(self, app):\n+        self.app = app\n+        self.cache = app.cache\n+        if self.mapper_name:\n+            self.mapper = get_mapper(self.mapper_name, app=self.app)\n+        else:\n+            self.mapper = None\n+\n+    async def exists(self, uid=None, **kwargs):\n+        if uid:\n+            if not kwargs and await self.cache.get(uid):\n+                return True\n+            kwargs[\"uid\"] = uid\n+        return await self.count(**kwargs) > 0\n+\n+    async def count(self, **kwargs):\n+        return await self.mapper.count(**kwargs)\n+\n+    async def new(self, **kwargs):\n+        return await self.mapper.new()\n+\n+    async def query(self, sql, *args):\n+        for record in self.app.db.query(sql, *args):\n+            yield record\n+\n+    async def get(self, uid=None, **kwargs):\n+        if uid:\n+            if not kwargs:\n+                result = await self.cache.get(uid)\n+                if False and result and result.__class__ == self.mapper.model_class:\n+                    return result\n+            kwargs[\"uid\"] = uid\n+\n+        result = await self.mapper.get(**kwargs)\n+        if result:\n+            await self.cache.set(result[\"uid\"], result)\n+        return result\n+\n+    async def save(self, model: UserModel):\n+        if await self.mapper.save(model):\n+            await self.cache.set(model[\"uid\"], model)\n+            return True\n+        errors = await model.errors\n+        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n+\n+    async def find(self, **kwargs):\n+        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+            kwargs[\"_limit\"] = 60\n+        async for model in self.mapper.find(**kwargs):\n+            yield model\n+\n+    async def delete(self, **kwargs):\n+        return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1630219..d4b6819 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,82 +1,249 @@\n-_G=':snek1:'\n-_F='status'\n-_E='_to_html'\n-_D='alias'\n-_C=True\n-_B='html.parser'\n-_A='href'\n import re\n from types import SimpleNamespace\n+\n import emoji\n from bs4 import BeautifulSoup\n-from jinja2 import TemplateSyntaxError,nodes\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />']={'en':_G,_F:2,'E':.6,_D:[_G]}\n-emoji.EMOJI_DATA['\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n']={'en':':a1:',_F:2,'E':.6,_D:[':a1:']}\n+\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n+    \"en\": \":snek1:\",\n+    \"status\": 2,\n+    \"E\": 0.6,\n+    \"alias\": [\":snek1:\"],\n+}\n+\n+emoji.EMOJI_DATA[\n+    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\"\"\"\n+] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n+\n+\n def set_link_target_blank(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):element.attrs['target']='_blank';element.attrs['rel']='noopener noreferrer';element.attrs['referrerpolicy']='no-referrer';element.attrs[_A]=element.attrs[_A].strip('.').strip(',')\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+\n+    for element in soup.find_all(\"a\"):\n+        element.attrs[\"target\"] = \"_blank\"\n+        element.attrs[\"rel\"] = \"noopener noreferrer\"\n+        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n+        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n+\n+    return str(soup)\n+\n+\n def embed_youtube(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\t\tvideo_name=element.attrs[_A].split('/')[-1]\n-\t\t\tif'v='in element.attrs[_A]:video_name=element.attrs[_A].split('?v=')[1].split('&')[0]\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def embed_image(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\tfor extension in['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.tiff','.ico','.heif']:\n-\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<img src=\"{element.attrs[_A]}\" title=\"{element.attrs[_A]}\" alt=\"{element.attrs[_A]}\" />';element.replace_with(BeautifulSoup(embed_template,_B))\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".png\",\n+            \".jpg\",\n+            \".jpeg\",\n+            \".gif\",\n+            \".webp\",\n+            \".svg\",\n+            \".bmp\",\n+            \".tiff\",\n+            \".ico\",\n+            \".heif\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n+                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def embed_media(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\tfor extension in['.mp4','.mp3','.wav','.ogg','.webm','.flac','.aac','.mpg','.avi','.wmv']:\n-\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<video controls> <source src=\"{element.attrs[_A]}\">Your browser does not support the video tag.</video>';element.replace_with(BeautifulSoup(embed_template,_B))\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".mp4\",\n+            \".mp3\",\n+            \".wav\",\n+            \".ogg\",\n+            \".webm\",\n+            \".flac\",\n+            \".aac\",\n+            \".mpg\",\n+            \".avi\",\n+            \".wmv\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n+                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def linkify_https(text):\n-\tfor element in soup.find_all(text=_C):\n-\t\tparent=element.parent\n-\t\tif parent.name in['a','script','style']:continue\n-\t\tnew_text=re.sub(url_pattern,'<a href=\"\\\\g<0>\">\\\\g<0></a>',element);element.replace_with(BeautifulSoup(new_text,_B))\n-\treturn set_link_target_blank(str(soup))\n+        return text\n+\n+    soup = BeautifulSoup(text, \"html.parser\")\n+\n+    for element in soup.find_all(text=True):\n+        parent = element.parent\n+        if parent.name in [\"a\", \"script\", \"style\"]:\n+            continue\n+\n+        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n+        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n+\n+    return set_link_target_blank(str(soup))\n+\n+\n class EmojiExtension(Extension):\n-\ttags={'emoji'}\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endemoji'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):return emoji.emojize(caller(),language=_D)\n+    tags = {\"emoji\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return emoji.emojize(caller(), language=\"alias\")\n+\n+\n class LinkifyExtension(Extension):\n-\ttags={'linkify'}\n-\tdef __init__(self,environment):self.app=SimpleNamespace(jinja2_env=environment);super(LinkifyExtension,self).__init__(environment)\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endlinkify'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):result=linkify_https(caller());result=embed_media(result);result=embed_image(result);result=embed_youtube(result);return result\n+    tags = {\"linkify\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(LinkifyExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        result = linkify_https(caller())\n+        result = embed_media(result)\n+        result = embed_image(result)\n+        result = embed_youtube(result)\n+        return result\n+\n+\n class PythonExtension(Extension):\n-\ttags={'py3'}\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endpy3'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):\n-\t\tdef fn(source):\n-\t\t\timport subprocess\n-\t\t\tdef system(command):\n-\t\t\t\tif isinstance(command):command=command.split(' ')\n-\t\t\t\tfrom io import StringIO;stdout=StringIO();subprocess.run(command,stderr=stdout,stdout=stdout,text=_C);return stdout.getvalue()\n-\t\t\tto_write=[]\n-\t\t\tdef render(text):global to_write;to_write.append(text)\n-\t\t\texec(source);return''.join(to_write)\n-\t\treturn str(fn(caller()))\n\\ No newline at end of file\n+    tags = {\"py3\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+\n+        def fn(source):\n+            import subprocess\n+\n+            def system(command):\n+                if isinstance(command):\n+                    command = command.split(\" \")\n+                from io import StringIO\n+\n+                stdout = StringIO()\n+                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n+                return stdout.getvalue()\n+\n+            to_write = []\n+\n+            def render(text):\n+                global to_write\n+                to_write.append(text)\n+\n+            exec(source)\n+            return \"\".join(to_write)\n+\n+        return str(fn(caller()))\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 82207c7..c5410b6 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,49 +1,113 @@\n-_A=None\n-import asyncio,os\n-try:import pty\n-except Exception as ex:print('You are not able to run a terminal. See error:');print(ex)\n+import asyncio\n+import os\n+\n+try:\n+    import pty\n+except Exception as ex:\n+    print(\"You are not able to run a terminal. See error:\")\n+    print(ex)\n import subprocess\n-commands={'alpine':'docker run -it alpine /bin/sh','r':'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh'}\n+\n+commands = {\n+    \"alpine\": \"docker run -it alpine /bin/sh\",\n+    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n+}\n+\n+\n class TerminalSession:\n-\tdef __init__(A,command):A.master,A.slave=_A,_A;A.process=_A;A.sockets=[];A.history=b'';A.history_size=20480;A.command=command;A.start_process(A.command)\n-\tdef start_process(A,command):\n-\t\tif not A.is_running():\n-\t\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n-\t\t\tA.master,A.slave=pty.openpty();A.process=subprocess.Popen(command.split(' '),stdin=A.slave,stdout=A.slave,stderr=A.slave,bufsize=0,universal_newlines=True)\n-\tdef is_running(A):\n-\t\tif not A.process:return False\n-\t\tasyncio.get_event_loop();return A.process.poll()is _A\n-\tasync def add_websocket(A,ws):A.start_process(A.command);asyncio.create_task(A.read_output(ws))\n-\tasync def read_output(A,ws):\n-\t\tB=ws;A.sockets.append(B)\n-\t\tif len(A.sockets)>1 and A.history:\n-\t\t\tD=0\n-\t\t\ttry:D=A.history.index(b'\\n')\n-\t\t\texcept ValueError:pass\n-\t\t\tawait B.send_bytes(A.history[D:]);return\n-\t\tE=asyncio.get_event_loop()\n-\t\twhile True:\n-\t\t\ttry:\n-\t\t\t\tC=await E.run_in_executor(_A,os.read,A.master,1024)\n-\t\t\t\tif not C:break\n-\t\t\t\tA.history+=C\n-\t\t\t\tif len(A.history)>A.history_size:A.history=A.history[:0-A.history_size]\n-\t\t\t\ttry:\n-\t\t\t\t\tfor B in A.sockets:await B.send_bytes(C)\n-\t\t\t\texcept:A.sockets.remove(B)\n-\t\t\texcept Exception:await A.close();break\n-\tasync def close(A):\n-\t\tprint('Terminating process')\n-\t\tif A.process:A.process.terminate();A.process=_A\n-\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n-\t\tprint('Terminated process')\n-\t\tfor B in A.sockets:\n-\t\t\ttry:await B.close()\n-\t\t\texcept Exception:pass\n-\t\tA.sockets=[]\n-\tasync def write_input(B,data):\n-\t\tA=data\n-\t\ttry:A=A.encode()\n-\t\texcept AttributeError:pass\n-\t\ttry:await asyncio.get_event_loop().run_in_executor(_A,os.write,B.master,A)\n-\t\texcept Exception as C:print(C);await B.close()\n\\ No newline at end of file\n+    def __init__(self, command):\n+        self.master, self.slave = None, None\n+        self.process = None\n+        self.sockets = []\n+        self.history = b\"\"\n+        self.history_size = 1024 * 20\n+        self.command = command\n+        self.start_process(self.command)\n+\n+    def start_process(self, command):\n+        if not self.is_running():\n+            if self.master:\n+                os.close(self.master)\n+                os.close(self.slave)\n+                self.master = None\n+                self.slave = None\n+\n+            self.master, self.slave = pty.openpty()\n+            self.process = subprocess.Popen(\n+                command.split(\" \"),\n+                stdin=self.slave,\n+                stdout=self.slave,\n+                stderr=self.slave,\n+                bufsize=0,\n+                universal_newlines=True,\n+            )\n+\n+    def is_running(self):\n+        if not self.process:\n+            return False\n+        asyncio.get_event_loop()\n+        return self.process.poll() is None\n+\n+    async def add_websocket(self, ws):\n+        self.start_process(self.command)\n+        asyncio.create_task(self.read_output(ws))\n+\n+    async def read_output(self, ws):\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1 and self.history:\n+            start = 0\n+            try:\n+                start = self.history.index(b\"\\n\")\n+            except ValueError:\n+                pass\n+            await ws.send_bytes(self.history[start:])\n+            return\n+        loop = asyncio.get_event_loop()\n+        while True:\n+            try:\n+                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n+                if not data:\n+                    break\n+                self.history += data\n+                if len(self.history) > self.history_size:\n+                    self.history = self.history[: 0 - self.history_size]\n+                try:\n+                    for ws in self.sockets:\n+                except:\n+                    self.sockets.remove(ws)\n+            except Exception:\n+                await self.close()\n+                break\n+\n+    async def close(self):\n+        print(\"Terminating process\")\n+        if self.process:\n+            self.process.terminate()\n+            self.process = None\n+        if self.master:\n+            os.close(self.master)\n+            os.close(self.slave)\n+            self.master = None\n+            self.slave = None\n+\n+        print(\"Terminated process\")\n+        for ws in self.sockets:\n+            try:\n+                await ws.close()\n+            except Exception:\n+                pass\n+        self.sockets = []\n+\n+    async def write_input(self, data):\n+        try:\n+            data = data.encode()\n+        except AttributeError:\n+            pass\n+        try:\n+            await asyncio.get_event_loop().run_in_executor(\n+                None, os.write, self.master, data\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            await self.close()\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex be19178..70379ef 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,31 +1,75 @@\n from aiohttp import web\n+\n from snek.system.markdown import render_markdown\n+\n+\n class BaseView(web.View):\n-\tlogin_required=False\n-\tasync def _iter(A):\n-\t\tif A.login_required and(not A.session.get('logged_in')or not A.session.get('uid')):return web.HTTPFound('/')\n-\t\treturn await super()._iter()\n-\t@property\n-\tdef base_url(self):return str(self.request.url.with_path('').with_query(''))\n-\t@property\n-\tdef app(self):return self.request.app\n-\t@property\n-\tdef db(self):return self.app.db\n-\t@property\n-\tdef services(self):return self.app.services\n-\tasync def json_response(B,data,**A):return web.json_response(data,**A)\n-\t@property\n-\tdef session(self):return self.request.session\n-\tasync def render_template(A,template_name,context=None):\n-\t\tC=context;B=template_name\n-\t\tif B.endswith('.md'):D=await A.request.app.render_template(B,A.request,C);E=await render_markdown(A.app,D.body.decode());return web.Response(body=E,content_type='text/html')\n-\t\treturn await A.request.app.render_template(B,A.request,C)\n+\n+    login_required = False\n+\n+    async def _iter(self):\n+        if self.login_required and (\n+            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n+        ):\n+            return web.HTTPFound(\"/\")\n+        return await super()._iter()\n+\n+    @property\n+    def base_url(self):\n+        return str(self.request.url.with_path(\"\").with_query(\"\"))\n+\n+    @property\n+    def app(self):\n+        return self.request.app\n+\n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    @property\n+    def services(self):\n+        return self.app.services\n+\n+    async def json_response(self, data, **kwargs):\n+        return web.json_response(data, **kwargs)\n+\n+    @property\n+    def session(self):\n+        return self.request.session\n+\n+    async def render_template(self, template_name, context=None):\n+        if template_name.endswith(\".md\"):\n+            response = await self.request.app.render_template(\n+                template_name, self.request, context\n+            )\n+            body = await render_markdown(self.app, response.body.decode())\n+            return web.Response(body=body, content_type=\"text/html\")\n+        return await self.request.app.render_template(\n+            template_name, self.request, context\n+        )\n+\n+\n class BaseFormView(BaseView):\n-\tform=None\n-\tasync def get(A):B=A.form(app=A.app);return await A.json_response(await B.to_json())\n-\tasync def post(A):\n-\t\tE='action';C=A.form(app=A.app);D=await A.request.json();C.set_user_data(D['form']);B=await C.to_json()\n-\t\tif D.get(E)=='validate':0\n-\t\tif D.get(E)=='submit'and B['is_valid']:B=await A.submit(C);return await A.json_response(B)\n-\t\treturn await A.json_response(B)\n-\tasync def submit(A,model=None):0\n\\ No newline at end of file\n+\n+    form = None\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+\n+        return await self.json_response(await form.to_json())\n+\n+    async def post(self):\n+        form = self.form(app=self.app)\n+        post = await self.request.json()\n+        form.set_user_data(post[\"form\"])\n+        result = await form.to_json()\n+        if post.get(\"action\") == \"validate\":\n+            pass\n+        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n+            result = await self.submit(form)\n+            return await self.json_response(result)\n+        return await self.json_response(result)\n+\n+    async def submit(self, model=None):\n+        pass\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 740ec7a..aba57ae 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,5 +1,39 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class AboutHTMLView(BaseView):\n-\tasync def get(A):return await A.render_template('about.html')\n+\n+    async def get(self):\n+        return await self.render_template(\"about.html\")\n+\n+\n class AboutMDView(BaseView):\n-\tasync def get(A):return await A.render_template('about.md')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex c95384a..a85b876 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -1,10 +1,44 @@\n+\n+\n+\n import uuid\n+\n from aiohttp import web\n from multiavatar import multiavatar\n+\n from snek.system.view import BaseView\n+\n+\n class AvatarView(BaseView):\n-\tlogin_required=False\n-\tasync def get(C):\n-\t\tA=C.request.match_info.get('uid')\n-\t\tif A=='unique':A=str(uuid.uuid4())\n-\t\tD=multiavatar.multiavatar(A,True,None);B=web.Response(text=D,content_type='image/svg+xml');B.headers['Cache-Control']=f\"public, max-age={56154}\";return B\n\\ No newline at end of file\n+    login_required = False\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        if uid == \"unique\":\n+            uid = str(uuid.uuid4())\n+        avatar = multiavatar.multiavatar(uid, True, None)\n+        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n+        return response\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex ce1e31c..bb63413 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,5 +1,37 @@\n+\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class DocsHTMLView(BaseView):\n-\tasync def get(A):return await A.render_template('docs.html')\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.html\")\n+\n+\n class DocsMDView(BaseView):\n-\tasync def get(A):return await A.render_template('docs.md')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 630cc3a..e3c3343 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,99 +1,269 @@\n-_P='Path not found'\n-_O='application/octet-stream'\n-_N='items'\n-_M='size'\n-_L='mimetype'\n-_K='name'\n-_J='rel_path'\n-_I='dir'\n-_H='url'\n-_G=None\n-_F='path'\n-_E='status'\n-_D='file'\n-_C='uid'\n-_B='absolute_url'\n-_A='type'\n from aiohttp import web\n+\n from snek.system.view import BaseView\n-import os,mimetypes\n+\n+\n+import os\n+import mimetypes\n from aiohttp import web\n-from urllib.parse import unquote,quote\n+from urllib.parse import unquote, quote\n from datetime import datetime\n+\n+\n+\n+\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n+\"\"\"\n from aiohttp import web\n from pathlib import Path\n-import mimetypes,urllib.parse\n-BASE_DIR=Path(__file__).parent.resolve()\n-ROOT_DIR=(BASE_DIR/'storage').resolve()\n-ASSETS_DIR=(BASE_DIR/'assets').resolve()\n+import mimetypes, urllib.parse\n+\n+BASE_DIR   = Path(__file__).parent.resolve()\n ROOT_DIR.mkdir(exist_ok=True)\n ASSETS_DIR.mkdir(exist_ok=True)\n-def safe_resolve_path(rel):\n-\t'Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.';A=(ROOT_DIR/rel.lstrip('/')).resolve()\n-\tif A==ROOT_DIR or ROOT_DIR in A.parents:return A\n-\traise FileNotFoundError('Unsafe path')\n+\n+\n+def safe_resolve_path(rel: str) -> Path:\n+    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n+    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n+    if target == ROOT_DIR or ROOT_DIR in target.parents:\n+        return target\n+    raise FileNotFoundError(\"Unsafe path\")\n+\n+\n class DriveView(BaseView):\n-\tasync def get(C):\n-\t\tH='limit';I='offset';D=C.request.query.get(_F,'');E=int(C.request.query.get(I,0));J=int(C.request.query.get(H,20));A=await C.services.user.get_home_folder(C.session.get(_C))\n-\t\tif D:A.joinpath(D)\n-\t\tif not A.exists():return web.json_response({'error':'Not found'},status=404)\n-\t\tif A.is_dir():\n-\t\t\tF=[]\n-\t\t\tfor B in sorted(A.iterdir(),key=lambda p:(p.is_file(),p.name.lower())):K=(Path(D)/B.name).as_posix();M=mimetypes.guess_type(B.name)[0]if B.is_file()else'inode/directory';G=C.request.url.with_path(f\"/drive/{urllib.parse.quote(K)}\")if B.is_file()else _G;F.append({_K:B.name,_A:'directory'if B.is_dir()else _D,_L:M,_M:B.stat().st_size if B.is_file()else _G,_F:K,_H:G})\n-\t\t\timport json as L;N=len(F);O=F[E:E+J];return web.json_response({_N:L.loads(L.dumps(O,default=str)),'pagination':{I:E,H:J,'total':N}})\n-\t\twith open(A,'rb')as P:Q=P.read();return web.Response(body=Q,content_type=mimetypes.guess_type(A.name)[0])\n-\t\tG=C.request.url.with_path(f\"/drive/{urllib.parse.quote(D)}\");return web.json_response({_K:A.name,_A:_D,_L:mimetypes.guess_type(A.name)[0],_M:A.stat().st_size,_F:D,_H:str(G)})\n+    async def get(self):\n+        rel = self.request.query.get(\"path\", \"\")\n+        offset = int(self.request.query.get(\"offset\", 0))\n+        limit  = int(self.request.query.get(\"limit\", 20))\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        if rel:\n+            target.joinpath(rel)\n+\n+        if not target.exists():\n+            return web.json_response({\"error\": \"Not found\"}, status=404)\n+\n+        if target.is_dir():\n+            entries = []\n+            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n+                item_path = (Path(rel) / p.name).as_posix()\n+                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n+                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n+                        if p.is_file() else None)\n+                entries.append({\n+                    \"name\": p.name,\n+                    \"type\": \"directory\" if p.is_dir() else \"file\",\n+                    \"mimetype\": mime,\n+                    \"size\": p.stat().st_size if p.is_file() else None,\n+                    \"path\": item_path,\n+                    \"url\": url,\n+                })\n+            import json \n+            total = len(entries)\n+            items = entries[offset:offset+limit]\n+            return web.json_response({\n+                \"items\": json.loads(json.dumps(items,default=str)),\n+                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n+            })\n+        \n+        with open(target, \"rb\") as f:\n+            content = f.read()\n+            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n+        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n+        return web.json_response({\n+            \"name\": target.name,\n+            \"type\": \"file\",\n+            \"mimetype\": mimetypes.guess_type(target.name)[0],\n+            \"size\": target.stat().st_size,\n+            \"path\": rel,\n+            \"url\": str(url),\n+        })\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n class DriveView222(BaseView):\n-\tPAGE_SIZE=20\n-\tasync def base_path(A):return await A.services.user.get_home_folder(A.session.get(_C))\n-\tasync def get_full_path(C,rel_path):\n-\t\tA=await C.base_path();D=os.path.normpath(unquote(rel_path or''));B=os.path.abspath(os.path.join(A,D))\n-\t\tif not B.startswith(os.path.abspath(A)):raise web.HTTPForbidden(reason='Invalid path')\n-\t\treturn B\n-\tasync def make_absolute_url(B,rel_path):A=rel_path;A=A.lstrip('/');C=str(B.request.url.with_path(f\"/drive/{quote(A)}\"));return C\n-\tasync def entry_details(E,dir_path,entry,parent_rel_path):A=entry;B=os.path.join(dir_path,A);C=os.stat(B);D=os.path.isdir(B);F=_G if D else mimetypes.guess_type(B)[0]or _O;G=C.st_size if not D else _G;H=datetime.fromtimestamp(C.st_ctime).isoformat();I=datetime.fromtimestamp(C.st_mtime).isoformat();J=os.path.join(parent_rel_path,A).replace('\\\\','/');return{_K:A,_A:_I if D else _D,_L:F,_M:G,'created_at':H,'updated_at':I,_B:await E.make_absolute_url(J)}\n-\tasync def get(A):\n-\t\tF='page_size';G='page';C=A.request.match_info.get(_J,'');B=await A.get_full_path(C);H=int(A.request.query.get(G,1));D=int(A.request.query.get(F,A.PAGE_SIZE));I=await A.make_absolute_url(C)\n-\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason=_P)\n-\t\tif os.path.isdir(B):E=os.listdir(B);E.sort();J=(H-1)*D;K=J+D;L=E[J:K];M=[await A.entry_details(B,D,C)for D in L];return web.json_response({_F:C,_B:I,'entries':M,'total':len(E),G:H,F:D})\n-\t\telse:\n-\t\t\twith open(B,'rb')as N:O=N.read()\n-\t\t\tP=mimetypes.guess_type(B)[0]or _O;Q={'X-Absolute-Url':I};return web.Response(body=O,content_type=P,headers=Q)\n-\tasync def post(A):\n-\t\tC='created';D=A.request.match_info.get(_J,'');B=await A.get_full_path(D);E=await A.make_absolute_url(D)\n-\t\tif os.path.exists(B):raise web.HTTPConflict(reason='File or directory already exists')\n-\t\tF=await A.request.post()\n-\t\tif F.get(_A)==_I:os.makedirs(B);return web.json_response({_E:C,_A:_I,_B:E})\n-\t\telse:\n-\t\t\tG=F.get(_D)\n-\t\t\tif not G:raise web.HTTPBadRequest(reason='No file uploaded')\n-\t\t\twith open(B,'wb')as H:H.write(G.file.read())\n-\t\t\treturn web.json_response({_E:C,_A:_D,_B:E})\n-\tasync def put(A):\n-\t\tC=A.request.match_info.get(_J,'');B=await A.get_full_path(C);D=await A.make_absolute_url(C)\n-\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason='File not found')\n-\t\tif os.path.isdir(B):raise web.HTTPBadRequest(reason='Cannot overwrite directory')\n-\t\tE=await A.request.read()\n-\t\twith open(B,'wb')as F:F.write(E)\n-\t\treturn web.json_response({_E:'updated',_B:D})\n-\tasync def delete(B):\n-\t\tC='deleted';D=B.request.match_info.get(_J,'');A=await B.get_full_path(D);E=await B.make_absolute_url(D)\n-\t\tif not os.path.exists(A):raise web.HTTPNotFound(reason=_P)\n-\t\tif os.path.isdir(A):os.rmdir(A);return web.json_response({_E:C,_A:_I,_B:E})\n-\t\telse:os.remove(A);return web.json_response({_E:C,_A:_D,_B:E})\n+    PAGE_SIZE = 20\n+\n+    async def base_path(self):\n+        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n+    async def get_full_path(self, rel_path):\n+        base_path = await self.base_path()\n+        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n+        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n+        if not full_path.startswith(os.path.abspath(base_path)):\n+            raise web.HTTPForbidden(reason=\"Invalid path\")\n+        return full_path\n+\n+    async def make_absolute_url(self, rel_path):\n+        rel_path = rel_path.lstrip(\"/\")\n+        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n+        return url\n+\n+    async def entry_details(self, dir_path, entry, parent_rel_path):\n+        entry_path = os.path.join(dir_path, entry)\n+        stat = os.stat(entry_path)\n+        is_dir = os.path.isdir(entry_path)\n+        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n+        size = stat.st_size if not is_dir else None\n+        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n+        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n+        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n+        return {\n+            \"name\": entry,\n+            \"type\": \"dir\" if is_dir else \"file\",\n+            \"mimetype\": mimetype,\n+            \"size\": size,\n+            \"created_at\": created_at,\n+            \"updated_at\": updated_at,\n+            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n+        }\n+\n+    async def get(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        page = int(self.request.query.get(\"page\", 1))\n+        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n+        abs_url = await self.make_absolute_url(rel_path)\n+\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+\n+        if os.path.isdir(full_path):\n+            entries = os.listdir(full_path)\n+            entries.sort()\n+            start = (page - 1) * page_size\n+            end = start + page_size\n+            paged_entries = entries[start:end]\n+            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n+            return web.json_response({\n+                \"path\": rel_path,\n+                \"absolute_url\": abs_url,\n+                \"entries\": details,\n+                \"total\": len(entries),\n+                \"page\": page,\n+                \"page_size\": page_size,\n+            })\n+        else:\n+            with open(full_path, \"rb\") as f:\n+                content = f.read()\n+            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n+            headers = {\"X-Absolute-Url\": abs_url}\n+            return web.Response(body=content, content_type=mimetype, headers=headers)\n+\n+    async def post(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if os.path.exists(full_path):\n+            raise web.HTTPConflict(reason=\"File or directory already exists\")\n+        data = await self.request.post()\n+        if data.get(\"type\") == \"dir\":\n+            os.makedirs(full_path)\n+            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            file_field = data.get(\"file\")\n+            if not file_field:\n+                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n+            with open(full_path, \"wb\") as f:\n+                f.write(file_field.file.read())\n+            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+    async def put(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"File not found\")\n+        if os.path.isdir(full_path):\n+            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n+        body = await self.request.read()\n+        with open(full_path, \"wb\") as f:\n+            f.write(body)\n+        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n+\n+    async def delete(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+        if os.path.isdir(full_path):\n+            os.rmdir(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            os.remove(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+\n class DriveViewi2(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):\n-\t\tG='/drive.bin/';D=A.request.match_info.get('drive');H=A.request.query.get('before');E={}\n-\t\tif H:E['created_at__lt']=H\n-\t\tif D:\n-\t\t\tE['drive_uid']=D;F=await A.services.drive.get(uid=D);I=[]\n-\t\t\tasync for C in A.services.drive_item.find(**E):B=C.record;B[_H]=G+B[_C]+'.'+C.extension;I.append(B)\n-\t\t\treturn web.json_response(I)\n-\t\tL=await A.services.user.get(uid=A.session.get(_C));J=[]\n-\t\tasync for F in A.services.drive.get_by_user(L[_C]):\n-\t\t\tB=F.record;B[_N]=[]\n-\t\t\tasync for C in F.items:K=C.record;K[_H]=G+K[_C]+'.'+C.extension;B[_N].append(C.record)\n-\t\t\tJ.append(B)\n-\t\treturn web.json_response(J)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        drive_uid = self.request.match_info.get(\"drive\")\n+            \n+\n+        before = self.request.query.get(\"before\")\n+        filters = {} \n+        if before:\n+            filters[\"created_at__lt\"] = before\n+\n+        if drive_uid:\n+            filters['drive_uid'] = drive_uid \n+            drive = await self.services.drive.get(uid=drive_uid)\n+            drive_items = []\n+            \n+            \n+            \n+            async for item in self.services.drive_item.find(**filters):\n+                record = item.record\n+                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n+                drive_items.append(record)\n+            return web.json_response(drive_items)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        drives = []\n+        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n+            record = drive.record\n+            record[\"items\"] = []\n+            async for item in drive.items:\n+                drive_item_record = item.record\n+                drive_item_record[\"url\"] = (\n+                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n+                )\n+                record[\"items\"].append(item.record)\n+            drives.append(record)\n+\n+        return web.json_response(drives)\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2bd3245..2f44443 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,6 +1,23 @@\n+\n+\n+\n+\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class IndexView(BaseView):\n-\tasync def get(A):\n-\t\tif A.session.get('uid'):return web.HTTPFound('/web.html')\n-\t\treturn await A.render_template('index.html')\n\\ No newline at end of file\n+    async def get(self):\n+        if self.session.get(\"uid\"):\n+            return web.HTTPFound(\"/web.html\")\n+\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 849a8e1..fe8cf4d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,15 +1,44 @@\n-_B='/web.html'\n-_A='logged_in'\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n+\n+\n class LoginView(BaseFormView):\n-\tform=LoginForm;login_required=False\n-\tasync def get(A):\n-\t\tif A.session.get(_A):return web.HTTPFound(_B)\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\treturn await A.render_template('login.html',{'form':await A.form(app=A.app).to_json()})\n-\tasync def submit(B,form):\n-\t\tD='color';E='uid';C='username'\n-\t\tif await form.is_valid:A=await B.services.user.get(username=form[C],deleted_at=None);await B.services.user.save(A);B.session.update({_A:True,C:A[C],E:A[E],D:A[D]});return{'redirect_url':_B}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = LoginForm\n+\n+    login_required = False\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\n+            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            user = await self.services.user.get(\n+                username=form[\"username\"], deleted_at=None\n+            )\n+            await self.services.user.save(user)\n+            self.session.update(\n+                {\n+                    \"logged_in\": True,\n+                    \"username\": user[\"username\"],\n+                    \"uid\": user[\"uid\"],\n+                    \"color\": user[\"color\"],\n+                }\n+            )\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 39b5be3..acf7c75 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,8 +1,23 @@\n+\n+\n+\n+\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n+\n+\n class LoginFormView(BaseFormView):\n-\tform=LoginForm\n-\tasync def submit(A,form):\n-\t\tB=form\n-\t\tif await B.is_valid():A.session['logged_in']=True;A.session['username']=B.username.value;A.session['uid']=B.uid.value;return{'redirect_url':'/web.html'}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = LoginForm\n+\n+    async def submit(self, form):\n+        if await form.is_valid():\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 5594774..42016d8 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,14 +1,56 @@\n-_B='username'\n-_A='logged_in'\n+\n+\n+\n+\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class LogoutView(BaseView):\n-\tredirect_url='/';login_required=True\n-\tasync def get(A):\n-\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n-\t\texcept KeyError:pass\n-\t\treturn web.HTTPFound(A.redirect_url)\n-\tasync def post(A):\n-\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n-\t\texcept KeyError:pass\n-\t\treturn await A.json_response({'redirect_url':A.redirect_url})\n\\ No newline at end of file\n+    redirect_url = \"/\"\n+    login_required = True\n+\n+    async def get(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return web.HTTPFound(self.redirect_url)\n+\n+    async def post(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex ba48820..96eed8a 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,12 +1,41 @@\n-_B='/web.html'\n-_A='logged_in'\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n+\n+\n class RegisterView(BaseFormView):\n-\tform=RegisterForm;login_required=False\n-\tasync def get(A):\n-\t\tif A.session.get(_A):return web.HTTPFound(_B)\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\treturn await A.render_template('register.html',{'form':await A.form(app=A.app).to_json()})\n-\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],_A:True,D:B[D]});return{'redirect_url':_B}\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    login_required = False\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\n+            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex cf5dbbb..7b98647 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,5 +1,47 @@\n+\n+\n+\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n+\n+\n class RegisterFormView(BaseFormView):\n-\tform=RegisterForm\n-\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],'logged_in':True,D:B[D]});return{'redirect_url':'/web.html'}\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 1896ba8..3161f49 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,105 +1,283 @@\n-_M='noresponse'\n-_L='deleted_at'\n-_K='Not allowed'\n-_J='password'\n-_I='logged_in'\n-_H='channel_uid'\n-_G='last_ping'\n-_F='nick'\n-_E=None\n-_D=True\n-_C=False\n-_B='username'\n-_A='uid'\n-import json,traceback\n+\n+\n+\n+\n+\n+import json\n+import traceback\n+\n from aiohttp import web\n+\n from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n+\n+\n class RPCView(BaseView):\n-\tclass RPCApi:\n-\t\tdef __init__(A,view,ws):A.view=view;A.app=A.view.app;A.services=A.app.services;A.ws=ws\n-\t\t@property\n-\t\tdef user_uid(self):return self.view.session.get(_A)\n-\t\t@property\n-\t\tdef request(self):return self.view.request\n-\t\tdef _require_login(A):\n-\t\t\tif not A.is_logged_in:raise Exception('Not logged in')\n-\t\t@property\n-\t\tdef is_logged_in(self):return self.view.session.get(_I,_C)\n-\t\tasync def mark_as_read(A,channel_uid):A._require_login();await A.services.channel_member.mark_as_read(channel_uid,A.user_uid);return _D\n-\t\tasync def login(A,username,password):\n-\t\t\tD=username;E=await A.services.user.validate_login(D,password)\n-\t\t\tif not E:raise Exception('Invalid username or password')\n-\t\t\tB=await A.services.user.get(username=D);A.view.session[_A]=B[_A];A.view.session[_I]=_D;A.view.session[_B]=B[_B];A.view.session['user_nick']=B[_F];C=B.record;del C[_J];del C[_L];await A.services.socket.add(A.ws,A.view.request.session.get(_A))\n-\t\t\tasync for F in A.services.channel_member.find(user_uid=A.view.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(A.ws,F[_H],A.view.request.session.get(_A))\n-\t\t\treturn C\n-\t\tasync def search_user(A,query):A._require_login();return[A[_B]for A in await A.services.user.search(query)]\n-\t\tasync def get_user(C,user_uid):\n-\t\t\tA=user_uid;C._require_login()\n-\t\t\tif not A:A=C.user_uid\n-\t\t\tD=await C.services.user.get(uid=A);B=D.record;del B[_J];del B[_L]\n-\t\t\tif A!=D[_A]:del B['email']\n-\t\t\treturn B\n-\t\tasync def get_messages(A,channel_uid,offset=0,timestamp=_E):\n-\t\t\tA._require_login();B=[]\n-\t\t\tfor C in await A.services.channel_message.offset(channel_uid,offset or 0,timestamp or _E):D=await A.services.channel_message.to_extended_dict(C);B.append(D)\n-\t\t\treturn B\n-\t\tasync def get_channels(B):\n-\t\t\tD='is_read_only';E='is_moderator';F='tag';G='color';C='new_count';B._require_login();H=[]\n-\t\t\tasync for A in B.services.channel_member.find(user_uid=B.user_uid,is_banned=_C):\n-\t\t\t\tI=await B.services.channel.get(uid=A[_H]);J=await I.get_last_message();K=_E\n-\t\t\t\tif J:L=await J.get_user();K=L[G]\n-\t\t\t\tH.append({'name':A['label'],_A:A[_H],F:I[F],C:A[C],E:A[E],D:A[D],C:A[C],G:K})\n-\t\t\treturn H\n-\t\tasync def send_message(A,channel_uid,message):A._require_login();await A.services.chat.send(A.user_uid,channel_uid,message);return _D\n-\t\tasync def echo(A,*B):A._require_login();return B\n-\t\tasync def query(B,*C):\n-\t\t\tB._require_login();E=C[0];D=E.lower()\n-\t\t\tif any(A in D for A in['drop','alter','update','delete','replace','insert','truncate'])and'select'not in D:raise Exception(_K)\n-\t\t\tF=[dict(A)async for A in B.services.channel.query(C[0])]\n-\t\t\tfor A in F:\n-\t\t\t\ttry:del A['email']\n-\t\t\t\texcept KeyError:pass\n-\t\t\t\ttry:del A[_J]\n-\t\t\t\texcept KeyError:pass\n-\t\t\t\ttry:del A['message']\n-\t\t\t\texcept:pass\n-\t\t\t\ttry:del A['html']\n-\t\t\t\texcept:pass\n-\t\t\treturn[dict(A)async for A in B.services.channel.query(C[0])]\n-\t\tasync def __call__(A,data):\n-\t\t\tI='success';E='data';F=data;B='callId'\n-\t\t\ttry:\n-\t\t\t\tG=F.get(B);C=F.get('method')\n-\t\t\t\tif C.startswith('_'):raise Exception(_K)\n-\t\t\t\tL=F.get('args')or[]\n-\t\t\t\tif hasattr(super(),C)or not hasattr(A,C):return await A._send_json({B:G,E:_K})\n-\t\t\t\tJ=getattr(A,C.replace('.','_'),_E)\n-\t\t\t\tif not J:raise Exception('Method not found')\n-\t\t\t\tK=_D\n-\t\t\t\ttry:H=await J(*L)\n-\t\t\t\texcept Exception as D:H={'exception':str(D),'traceback':traceback.format_exc()};K=_C\n-\t\t\t\tif H!=_M:await A._send_json({B:G,I:K,E:H})\n-\t\t\texcept Exception as D:print(str(D),flush=_D);await A._send_json({B:G,I:_C,E:str(D)})\n-\t\tasync def _send_json(A,obj):await A.ws.send_str(json.dumps(obj,default=str))\n-\t\tasync def get_online_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_online_users(channel_uid)]\n-\t\tasync def echo(A,obj):await A.ws.send_json(obj);return _M\n-\t\tasync def get_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_users(channel_uid)]\n-\t\tasync def ping(A,callId,*C):\n-\t\t\tif A.user_uid:B=await A.services.user.get(uid=A.user_uid);B[_G]=now();await A.services.user.save(B)\n-\t\t\treturn{'pong':C}\n-\tasync def get(A):\n-\t\tB=web.WebSocketResponse();await B.prepare(A.request)\n-\t\tif A.request.session.get(_I):\n-\t\t\tawait A.services.socket.add(B,A.request.session.get(_A))\n-\t\t\tasync for D in A.services.channel_member.find(user_uid=A.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(B,D[_H],A.request.session.get(_A))\n-\t\tE=RPCView.RPCApi(A,B)\n-\t\tasync for C in B:\n-\t\t\tif C.type==web.WSMsgType.TEXT:\n-\t\t\t\ttry:\n-\t\t\t\t\tasync with Profiler():await E(C.json())\n-\t\t\t\texcept Exception as F:print('Deleting socket',F,flush=_D);await A.services.socket.delete(B);break\n-\t\t\telif C.type==web.WSMsgType.ERROR:0\n-\t\t\telif C.type==web.WSMsgType.CLOSE:0\n-\t\treturn B\n\\ No newline at end of file\n+\n+    class RPCApi:\n+        def __init__(self, view, ws):\n+            self.view = view\n+            self.app = self.view.app\n+            self.services = self.app.services\n+            self.ws = ws\n+\n+        @property\n+        def user_uid(self):\n+            return self.view.session.get(\"uid\")\n+\n+        @property\n+        def request(self):\n+            return self.view.request\n+\n+        def _require_login(self):\n+            if not self.is_logged_in:\n+                raise Exception(\"Not logged in\")\n+\n+        @property\n+        def is_logged_in(self):\n+            return self.view.session.get(\"logged_in\", False)\n+\n+        async def mark_as_read(self, channel_uid):\n+            self._require_login()\n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n+            return True\n+\n+        async def login(self, username, password):\n+            success = await self.services.user.validate_login(username, password)\n+            if not success:\n+                raise Exception(\"Invalid username or password\")\n+            user = await self.services.user.get(username=username)\n+            self.view.session[\"uid\"] = user[\"uid\"]\n+            self.view.session[\"logged_in\"] = True\n+            self.view.session[\"username\"] = user[\"username\"]\n+            self.view.session[\"user_nick\"] = user[\"nick\"]\n+            record = user.record\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            await self.services.socket.add(\n+                self.ws, self.view.request.session.get(\"uid\")\n+            )\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.view.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    self.ws,\n+                    subscription[\"channel_uid\"],\n+                    self.view.request.session.get(\"uid\"),\n+                )\n+            return record\n+\n+        async def search_user(self, query):\n+            self._require_login()\n+            return [user[\"username\"] for user in await self.services.user.search(query)]\n+\n+        async def get_user(self, user_uid):\n+            self._require_login()\n+            if not user_uid:\n+                user_uid = self.user_uid\n+            user = await self.services.user.get(uid=user_uid)\n+            record = user.record\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            if user_uid != user[\"uid\"]:\n+                del record[\"email\"]\n+            return record\n+\n+        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n+            self._require_login()\n+            messages = []\n+            for message in await self.services.channel_message.offset(\n+                channel_uid, offset or 0, timestamp or None\n+            ):\n+                extended_dict = await self.services.channel_message.to_extended_dict(\n+                    message\n+                )\n+                messages.append(extended_dict)\n+            return messages\n+\n+        async def get_channels(self):\n+            self._require_login()\n+            channels = []\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.user_uid, is_banned=False\n+            ):\n+                channel = await self.services.channel.get(\n+                    uid=subscription[\"channel_uid\"]\n+                )\n+                last_message = await channel.get_last_message()\n+                color = None\n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                channels.append(\n+                    {\n+                        \"name\": subscription[\"label\"],\n+                        \"uid\": subscription[\"channel_uid\"],\n+                        \"tag\": channel[\"tag\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"is_moderator\": subscription[\"is_moderator\"],\n+                        \"is_read_only\": subscription[\"is_read_only\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"color\": color,\n+                    }\n+                )\n+            return channels\n+\n+        async def send_message(self, channel_uid, message):\n+            self._require_login()\n+            await self.services.chat.send(self.user_uid, channel_uid, message)\n+            return True\n+\n+        async def echo(self, *args):\n+            self._require_login()\n+            return args\n+\n+        async def query(self, *args):\n+            self._require_login()\n+            query = args[0]\n+            lowercase = query.lower()\n+            if (\n+                any(\n+                    keyword in lowercase\n+                    for keyword in [\n+                        \"drop\",\n+                        \"alter\",\n+                        \"update\",\n+                        \"delete\",\n+                        \"replace\",\n+                        \"insert\",\n+                        \"truncate\",\n+                    ]\n+                )\n+                and \"select\" not in lowercase\n+            ):\n+                raise Exception(\"Not allowed\")\n+            records = [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n+            for record in records:\n+                try:\n+                    del record[\"email\"]\n+                except KeyError:\n+                    pass\n+                try:\n+                    del record[\"password\"]\n+                except KeyError:\n+                    pass\n+                try:\n+                    del record[\"message\"]\n+                except:\n+                    pass\n+                try:\n+                    del record[\"html\"]\n+                except:\n+                    pass\n+            return [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n+\n+        async def __call__(self, data):\n+            try:\n+                call_id = data.get(\"callId\")\n+                method_name = data.get(\"method\")\n+                if method_name.startswith(\"_\"):\n+                    raise Exception(\"Not allowed\")\n+                args = data.get(\"args\") or []\n+                if hasattr(super(), method_name) or not hasattr(self, method_name):\n+                    return await self._send_json(\n+                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n+                    )\n+                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n+                if not method:\n+                    raise Exception(\"Method not found\")\n+                success = True\n+                try:\n+                    result = await method(*args)\n+                except Exception as ex:\n+                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n+                    success = False\n+                if result != \"noresponse\":\n+                    await self._send_json(\n+                        {\"callId\": call_id, \"success\": success, \"data\": result}\n+                    )\n+            except Exception as ex:\n+                print(str(ex), flush=True)\n+                await self._send_json(\n+                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n+                )\n+\n+        async def _send_json(self, obj):\n+            await self.ws.send_str(json.dumps(obj, default=str))\n+\n+        async def get_online_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_online_users(channel_uid)\n+            ]\n+\n+        async def echo(self, obj):\n+            await self.ws.send_json(obj)\n+            return \"noresponse\"\n+\n+        async def get_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_users(channel_uid)\n+            ]\n+\n+        async def ping(self, callId, *args):\n+            if self.user_uid:\n+                user = await self.services.user.get(uid=self.user_uid)\n+                user[\"last_ping\"] = now()\n+                await self.services.user.save(user)\n+            return {\"pong\": args}\n+\n+    async def get(self):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        if self.request.session.get(\"logged_in\"):\n+            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n+                )\n+        rpc = RPCView.RPCApi(self, ws)\n+        async for msg in ws:\n+            if msg.type == web.WSMsgType.TEXT:\n+                try:\n+                    async with Profiler():\n+                        await rpc(msg.json())\n+                except Exception as ex:\n+                    print(\"Deleting socket\", ex, flush=True)\n+                    await self.services.socket.delete(ws)\n+                    break\n+            elif msg.type == web.WSMsgType.ERROR:\n+                pass\n+            elif msg.type == web.WSMsgType.CLOSE:\n+                pass\n+        return ws\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 5e5b7e2..1f09a26 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,12 +1,56 @@\n+\n+\n+\n+\n+\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n+\n+\n class SearchUserView(BaseFormView):\n-\tform=SearchUserForm;login_required=True\n-\tasync def get(A):\n-\t\tC='query';D=[];B=A.request.query.get(C)\n-\t\tif B:D=[A.record for A in await A.app.services.user.search(B)]\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\tE=await A.app.services.user.get(uid=A.session.get('uid'));return await A.render_template('search_user.html',{'users':D,C:B or'','current_user':E})\n-\tasync def submit(A,form):\n-\t\tif await form.is_valid:return{'redirect_url':'/search-user.html?query='+form['username']}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = SearchUserForm\n+    login_required = True\n+\n+    async def get(self):\n+        users = []\n+        query = self.request.query.get(\"query\")\n+        if query:\n+            users = [user.record for user in await self.app.services.user.search(query)]\n+\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        return await self.render_template(\n+            \"search_user.html\",\n+            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n+        )\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex fc58857..418ef3d 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,4 +1,9 @@\n from snek.system.view import BaseView\n+\n+\n class SettingsIndexView(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):return await A.render_template('settings/index.html')\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template(\"settings/index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4a3f897..164c526 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,13 +1,38 @@\n-_C='profile'\n-_B='uid'\n-_A='nick'\n from aiohttp import web\n+\n from snek.form.settings.profile import SettingsProfileForm\n from snek.system.view import BaseFormView\n+\n+\n class SettingsProfileView(BaseFormView):\n-\tform=SettingsProfileForm;login_required=True\n-\tasync def get(A):\n-\t\tC='user';B=A.form(app=A.app)\n-\t\tif A.request.path.endswith('.json'):B[_A]=A.request[C][_A];return web.json_response(await B.to_json())\n-\t\tD=await A.services.user_property.get(A.session.get(_B),_C);E=await A.services.user.get(uid=A.session.get(_B));return await A.render_template('settings/profile.html',{'form':await B.to_json(),C:E,_C:D or''})\n-\tasync def post(A):C=await A.request.post();B=await A.services.user.get(uid=A.session.get(_B));B[_A]=C[_A];await A.services.user.save(B);await A.services.user_property.set(B[_B],_C,C[_C]);return web.HTTPFound('/settings/profile.html')\n\\ No newline at end of file\n+    form = SettingsProfileForm\n+\n+    login_required = True\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+\n+        if self.request.path.endswith(\".json\"):\n+            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+\n+            return web.json_response(await form.to_json())\n+\n+        profile = await self.services.user_property.get(\n+            self.session.get(\"uid\"), \"profile\"\n+        )\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        return await self.render_template(\n+            \"settings/profile.html\",\n+            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n+        )\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        user[\"nick\"] = data[\"nick\"]\n+        await self.services.user.save(user)\n+        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n+        return web.HTTPFound(\"/settings/profile.html\")\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 1cf96fd..093d229 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -1,37 +1,86 @@\n-_F='repository'\n-_E='/settings/repositories/index.html'\n-_D='is_private'\n-_C=True\n-_B='name'\n-_A='uid'\n import asyncio\n from aiohttp import web\n+\n from snek.system.view import BaseFormView\n import pathlib\n+\n class RepositoriesIndexView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tC=A.session.get(_A);B=[]\n-\t\tasync for D in A.services.repository.find(user_uid=C):B.append(D.record)\n-\t\tE=await A.services.user.get(uid=A.session.get(_A));return await A.render_template('settings/repositories/index.html',{'repositories':B,'user':E})\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        user_uid = self.session.get(\"uid\")\n+        \n+        repositories = []\n+        async for repository in self.services.repository.find(user_uid=user_uid):\n+            repositories.append(repository.record)\n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})\n+\n+\n+\n+\n class RepositoriesCreateView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):return await A.render_template('settings/repositories/create.html')\n-\tasync def post(A):B=await A.request.post();C=await A.services.repository.create(user_uid=A.session.get(_A),name=B[_B],is_private=int(B.get(_D,0)));return web.HTTPFound(_E)\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/create.html\")\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n class RepositoriesUpdateView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n-\t\tif not B:return web.HTTPNotFound()\n-\t\treturn await A.render_template('settings/repositories/update.html',{_F:B.record})\n-\tasync def post(A):C=await A.request.post();B=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B]);B[_D]=int(C.get(_D,0));await A.services.repository.save(B);return web.HTTPFound(_E)\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        repository['is_private'] = int(data.get('is_private',0))\n+        await self.services.repository.save(repository)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n class RepositoriesDeleteView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n-\t\tif not B:return web.HTTPNotFound()\n-\t\treturn await A.render_template('settings/repositories/delete.html',{_F:B.record})\n-\tasync def post(A):\n-\t\tB=A.session.get(_A);C=A.request.match_info[_B];D=await A.services.repository.get(user_uid=B,name=C)\n-\t\tif not D:return web.HTTPNotFound()\n-\t\tawait A.services.repository.delete(user_uid=B,name=C);return web.HTTPFound(_E)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+            \n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        user_uid = self.session.get(\"uid\")\n+        name = self.request.match_info[\"name\"]\n+        repository = await self.services.repository.get(\n+                user_uid=user_uid, name=name\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        await self.services.repository.delete(user_uid=user_uid, name=name)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex dbf7fc6..1680c5c 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,5 +1,13 @@\n import json\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class StatsView(BaseView):\n-\tasync def get(B):A=await B.app.cache.get_stats();A=json.dumps({'total':len(A),'stats':A},default=str,indent=1);return web.Response(text=A,content_type='application/json')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        data = await self.app.cache.get_stats()\n+        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n+        return web.Response(text=data, content_type=\"application/json\")\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 672d20f..4675572 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,10 +1,73 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class StatusView(BaseView):\n-\tasync def get(C):\n-\t\tG='color';H='nick';I='email';J='username';K='is_banned';L='is_muted';M='is_read_only';N='is_moderator';O='user_uid';P='description';E='channel_uid';D='uid';Q=[];A={};F=C.session.get(D)\n-\t\tif F:\n-\t\t\tA=await C.app.services.user.get(uid=F)\n-\t\t\tif not A:return await C.json_response({'error':'User not found'},status=404)\n-\t\t\tasync for B in C.app.services.channel_member.find(user_uid=F,deleted_at=None,is_banned=False):R=await C.app.services.channel.get(uid=B[E]);Q.append({'name':R['label'],P:B[P],O:B[O],N:B[N],M:B[M],L:B[L],K:B[K],E:B[E],D:B[D]})\n-\t\t\tA={J:A[J],I:A[I],H:A[H],D:A[D],G:A[G],'memberships':Q}\n-\t\treturn await C.json_response({'user':A,'cache':await C.app.cache.create_cache_key(C.app.cache.cache,None)})\n\\ No newline at end of file\n+    async def get(self):\n+        memberships = []\n+        user = {}\n+\n+        user_id = self.session.get(\"uid\")\n+        if user_id:\n+            user = await self.app.services.user.get(uid=user_id)\n+            if not user:\n+                return await self.json_response({\"error\": \"User not found\"}, status=404)\n+\n+            async for model in self.app.services.channel_member.find(\n+                user_uid=user_id, deleted_at=None, is_banned=False\n+            ):\n+                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n+                memberships.append(\n+                    {\n+                        \"name\": channel[\"label\"],\n+                        \"description\": model[\"description\"],\n+                        \"user_uid\": model[\"user_uid\"],\n+                        \"is_moderator\": model[\"is_moderator\"],\n+                        \"is_read_only\": model[\"is_read_only\"],\n+                        \"is_muted\": model[\"is_muted\"],\n+                        \"is_banned\": model[\"is_banned\"],\n+                        \"channel_uid\": model[\"channel_uid\"],\n+                        \"uid\": model[\"uid\"],\n+                    }\n+                )\n+            user = {\n+                \"username\": user[\"username\"],\n+                \"email\": user[\"email\"],\n+                \"nick\": user[\"nick\"],\n+                \"uid\": user[\"uid\"],\n+                \"color\": user[\"color\"],\n+                \"memberships\": memberships,\n+            }\n+\n+        return await self.json_response(\n+            {\n+                \"user\": user,\n+                \"cache\": await self.app.cache.create_cache_key(\n+                    self.app.cache.cache, None\n+                ),\n+            }\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 43c1fd1..d3af9b0 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,23 +1,54 @@\n-_B=True\n-_A='uid'\n-import pathlib,aiohttp\n+import pathlib\n+\n+import aiohttp\n+\n from snek.system.terminal import TerminalSession\n from snek.system.view import BaseView\n+\n+\n class TerminalSocketView(BaseView):\n-\tlogin_required=_B;user_sessions={}\n-\tasync def prepare_drive(C):\n-\t\tD=await C.services.user.get(uid=C.session.get(_A));A=pathlib.Path('drive').joinpath(D[_A]);A.mkdir(parents=_B,exist_ok=_B);E=pathlib.Path('terminal')\n-\t\tfor B in E.iterdir():\n-\t\t\tF=A.joinpath(B.name)\n-\t\t\tif not B.is_dir():F.write_bytes(B.read_bytes())\n-\t\treturn A\n-\tasync def get(A):\n-\t\tB=aiohttp.web.WebSocketResponse();await B.prepare(A.request);D=await A.services.user.get(uid=A.session.get(_A));F=await A.prepare_drive();G=f\"docker run -v ./{F}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\";C=A.user_sessions.get(D[_A])\n-\t\tif not C:A.user_sessions[D[_A]]=TerminalSession(command=G)\n-\t\tC=A.user_sessions[D[_A]];await C.add_websocket(B)\n-\t\tasync for E in B:\n-\t\t\tif E.type==aiohttp.WSMsgType.BINARY:await C.write_input(E.data.decode())\n-\t\treturn B\n+\n+    login_required = True\n+\n+    user_sessions = {}\n+\n+    async def prepare_drive(self):\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n+        root.mkdir(parents=True, exist_ok=True)\n+        terminal_folder = pathlib.Path(\"terminal\")\n+        for path in terminal_folder.iterdir():\n+            destination_path = root.joinpath(path.name)\n+            if not path.is_dir():\n+                destination_path.write_bytes(path.read_bytes())\n+        return root\n+\n+    async def get(self):\n+\n+        ws = aiohttp.web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = await self.prepare_drive()\n+\n+        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n+\n+        session = self.user_sessions.get(user[\"uid\"])\n+        if not session:\n+            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n+        session = self.user_sessions[user[\"uid\"]]\n+        await session.add_websocket(ws)\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.BINARY:\n+                await session.write_input(msg.data.decode())\n+\n+        return ws\n+\n+\n class TerminalView(BaseView):\n-\tlogin_required=_B\n-\tasync def get(A):return await A.request.app.render_template('terminal.html',A.request)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+        return await self.request.app.render_template(\"terminal.html\", self.request)\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 3b7425d..bc923c6 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,11 +1,37 @@\n from snek.system.view import BaseView\n+\n+\n class ThreadsView(BaseView):\n-\tasync def get(B):\n-\t\tI='color';J='user_uid';K='name_color';L='new_count';F='uid';C='last_message_on';G=[];M=await B.services.user.get(uid=B.session.get(F))\n-\t\tasync for H in M.get_channel_members():\n-\t\t\tA={};D=await B.services.channel.get(uid=H['channel_uid']);E=await D.get_last_message()\n-\t\t\tif not E:continue\n-\t\t\tif D['tag']=='dm':A[K]=N[I]\n-\t\t\tA['last_message_user_color']=N[I];G.append(A)\n-\t\tG.sort(key=lambda x:x[C]or'',reverse=True);return await B.render_template('threads.html',{'threads':G,'user':M})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        threads = []\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        async for channel_member in user.get_channel_members():\n+            thread = {}\n+            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            last_message = await channel.get_last_message()\n+            if not last_message:\n+                continue\n+\n+            thread[\"uid\"] = channel[\"uid\"]\n+            thread[\"name\"] = await channel_member.get_name()\n+            thread[\"new_count\"] = channel_member[\"new_count\"]\n+            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n+            thread[\"created_at\"] = thread[\"last_message_on\"]\n+\n+            thread[\"last_message_text\"] = last_message[\"message\"]\n+            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(\n+                uid=last_message[\"user_uid\"]\n+            )\n+            if channel[\"tag\"] == \"dm\":\n+                thread[\"name_color\"] = user_last_message[\"color\"]\n+            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n+            threads.append(thread)\n+\n+        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+\n+        return await self.render_template(\n+            \"threads.html\", {\"threads\": threads, \"user\": user}\n+        )\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex e45d5f6..cf01948 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,19 +1,111 @@\n-_A='uid'\n-import pathlib,uuid,aiofiles\n+\n+\n+\n+\n+import pathlib\n+import uuid\n+\n+import aiofiles\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class UploadView(BaseView):\n-\tasync def get(B):D=B.request.match_info.get(_A);C=await B.services.drive_item.get(D);A=web.FileResponse(C['path']);A.headers['Cache-Control']=f\"public, max-age={561540}\";A.headers['Content-Disposition']=f'attachment; filename=\"{C[\"name\"]}\"';return A\n-\tasync def post(A):\n-\t\tK='](/drive.bin/';L='channel_uid';G='document';D='image';P=await A.request.multipart();M=[];Q=A.request.session.get(_A);E=await A.services.user.get_home_folder(Q);E=E.joinpath('upload');E.mkdir(parents=True,exist_ok=True);H=None;R=await A.services.drive.get_or_create(user_uid=A.request.session.get(_A));N={'.jpg':D,'.gif':D,'.png':D,'.jpeg':D,'.mp4':'video','.mp3':'audio','.pdf':G,'.doc':G,'.docx':G}\n-\t\twhile(F:=await P.next()):\n-\t\t\tif F.name==L:H=await F.text();continue\n-\t\t\tB=F.filename\n-\t\t\tif not B:continue\n-\t\t\tS=str(uuid.uuid4())+pathlib.Path(B).suffix;C=E.joinpath(S);M.append(C)\n-\t\t\tasync with aiofiles.open(str(C),'wb')as T:\n-\t\t\t\twhile(U:=await F.read_chunk()):await T.write(U)\n-\t\t\tI=await A.services.drive_item.create(R[_A],B,str(C),C.stat().st_size,C.suffix);J='.'+B.split('.')[-1]\n-\t\t\tif J in N:N[J]\n-\t\t\tawait A.services.drive_item.save(I);O='Uploaded ['+B+K+I[_A]+')';O='['+B+K+I[_A]+J+')';await A.services.chat.send(A.request.session.get(_A),H,O)\n-\t\treturn web.json_response({'message':'Files uploaded successfully','files':[str(A)for A in M],L:H})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        drive_item = await self.services.drive_item.get(uid)\n+        response = web.FileResponse(drive_item[\"path\"])\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        )\n+        return response\n+\n+    async def post(self):\n+        reader = await self.request.multipart()\n+        files = []\n+\n+        user_uid = self.request.session.get(\"uid\")\n+\n+        upload_dir = await self.services.user.get_home_folder(user_uid)\n+        upload_dir = upload_dir.joinpath(\"upload\")\n+        upload_dir.mkdir(parents=True, exist_ok=True)\n+\n+        channel_uid = None\n+\n+        drive = await self.services.drive.get_or_create(\n+            user_uid=self.request.session.get(\"uid\")\n+        )\n+\n+        extension_types = {\n+            \".jpg\": \"image\",\n+            \".gif\": \"image\",\n+            \".png\": \"image\",\n+            \".jpeg\": \"image\",\n+            \".mp4\": \"video\",\n+            \".mp3\": \"audio\",\n+            \".pdf\": \"document\",\n+            \".doc\": \"document\",\n+            \".docx\": \"document\",\n+        }\n+\n+        while field := await reader.next():\n+            if field.name == \"channel_uid\":\n+                channel_uid = await field.text()\n+                continue\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+\n+            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n+\n+            file_path = upload_dir.joinpath(name)\n+            files.append(file_path)\n+\n+            async with aiofiles.open(str(file_path), \"wb\") as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+            drive_item = await self.services.drive_item.create(\n+                drive[\"uid\"],\n+                filename,\n+                str(file_path),\n+                file_path.stat().st_size,\n+                file_path.suffix,\n+            )\n+\n+            extension = \".\" + filename.split(\".\")[-1]\n+            if extension in extension_types:\n+                extension_types[extension]\n+\n+            await self.services.drive_item.save(drive_item)\n+            response = (\n+                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            )\n+            response = (\n+                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            )\n+\n+            await self.services.chat.send(\n+                self.request.session.get(\"uid\"), channel_uid, response\n+            )\n+\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [str(file) for file in files],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex ab00e1a..312f7bf 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -1,3 +1,15 @@\n from snek.system.view import BaseView\n+\n+\n class UserView(BaseView):\n-\tasync def get(A):B='profile';C='user';D=A.request.match_info.get(C);E=await A.services.user.get(uid=D);F=await A.services.user_property.get(E['uid'],B)or'';return await A.render_template('user.html',{'user_uid':D,C:E.record,B:F})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        user_uid = self.request.match_info.get(\"user\")\n+        user = await self.services.user.get(uid=user_uid)\n+        profile_content = (\n+            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n+        )\n+        return await self.render_template(\n+            \"user.html\",\n+            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n+        )\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 292586d..111f76c 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,19 +1,79 @@\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class WebView(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):\n-\t\tF='channel';B='uid'\n-\t\tif A.login_required and not A.session.get('logged_in'):return web.HTTPFound('/')\n-\t\tC=await A.services.channel.get(uid=A.request.match_info.get(F))\n-\t\tif not C:\n-\t\t\tD=await A.services.user.get(uid=A.request.match_info.get(F))\n-\t\t\tif D:\n-\t\t\t\tC=await A.services.channel.get_dm(A.session.get(B),D[B])\n-\t\t\t\tif C:return web.HTTPFound('/channel/{}.html'.format(C[B]))\n-\t\tif not C:return web.HTTPNotFound()\n-\t\tE=await A.app.services.channel_member.get(user_uid=A.session.get(B),channel_uid=C[B])\n-\t\tif not E:return web.HTTPNotFound()\n-\t\tE['new_count']=0;await A.app.services.channel_member.save(E);D=await A.services.user.get(uid=A.session.get(B));G=[await A.app.services.channel_message.to_extended_dict(B)for B in await A.app.services.channel_message.offset(C[B])]\n-\t\tfor H in G:await A.app.services.notification.mark_as_read(A.session.get(B),H[B])\n-\t\tI=await E.get_name();return await A.render_template('web.html',{'name':I,F:C,'user':D,'messages':G})\n\\ No newline at end of file\n+    login_required = True\n+\n+    async def get(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        channel = await self.services.channel.get(\n+            uid=self.request.match_info.get(\"channel\")\n+        )\n+        if not channel:\n+            user = await self.services.user.get(\n+                uid=self.request.match_info.get(\"channel\")\n+            )\n+            if user:\n+                channel = await self.services.channel.get_dm(\n+                    self.session.get(\"uid\"), user[\"uid\"]\n+                )\n+                if channel:\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        if not channel:\n+            return web.HTTPNotFound()\n+\n+        channel_member = await self.app.services.channel_member.get(\n+            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n+        )\n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n+        channel_member[\"new_count\"] = 0\n+        await self.app.services.channel_member.save(channel_member)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        messages = [\n+            await self.app.services.channel_message.to_extended_dict(message)\n+            for message in await self.app.services.channel_message.offset(\n+                channel[\"uid\"]\n+            )\n+        ]\n+        for message in messages:\n+            await self.app.services.notification.mark_as_read(\n+                self.session.get(\"uid\"), message[\"uid\"]\n+            )\n+\n+        name = await channel_member.get_name()\n+        return await self.render_template(\n+            \"web.html\",\n+            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n+        )\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 0d0ae16..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,145 +1,377 @@\n-_U='Lock-Token'\n-_T='application/xml'\n-_S='{DAV:}exclusive'\n-_R='{DAV:}lockdiscovery'\n-_Q='{DAV:}prop'\n-_P='%a, %d %b %Y %H:%M:%S GMT'\n-_O='Source not found'\n-_M='Destination'\n-_L='application/octet-stream'\n-_K='File not found'\n-_J='{DAV:}write'\n-_I='{DAV:}locktype'\n-_H='{DAV:}lockscope'\n-_G='{DAV:}href'\n-_F='Content-Type'\n-_E=True\n-_D='filename'\n-_C='Basic realm=\"WebDAV\"'\n-_B='WWW-Authenticate'\n-_A='home'\n-import logging,pathlib\n+import logging\n+import pathlib\n+\n logging.basicConfig(level=logging.DEBUG)\n-import base64,datetime,mimetypes,os,shutil,uuid,aiofiles,aiohttp,aiohttp.web\n+import base64\n+import datetime\n+import mimetypes\n+import os\n+import shutil\n+import uuid\n+\n+import aiofiles\n+import aiohttp\n+import aiohttp.web\n from app.cache import time_cache_async\n from lxml import etree\n+\n+\n @aiohttp.web.middleware\n-async def debug_middleware(request,handler):\n-\tA=request;print(A.method,A.path,A.headers);B=await handler(A);print(B.status)\n-\ttry:print(await B.text())\n-\texcept:pass\n-\treturn B\n+async def debug_middleware(request, handler):\n+    print(request.method, request.path, request.headers)\n+    result = await handler(request)\n+    print(result.status)\n+    try:\n+        print(await result.text())\n+    except:\n+        pass\n+    return result\n+\n+\n class WebdavApplication(aiohttp.web.Application):\n-\tdef __init__(A,parent,*C,**D):B='/{filename:.*}';E=[debug_middleware];super().__init__(*C,middlewares=E,**D);A.locks={};A.relative_url='/webdav';A.router.add_route('OPTIONS',B,A.handle_options);A.router.add_route('GET',B,A.handle_get);A.router.add_route('PUT',B,A.handle_put);A.router.add_route('DELETE',B,A.handle_delete);A.router.add_route('MKCOL',B,A.handle_mkcol);A.router.add_route('MOVE',B,A.handle_move);A.router.add_route('COPY',B,A.handle_copy);A.router.add_route('PROPFIND',B,A.handle_propfind);A.router.add_route('PROPPATCH',B,A.handle_proppatch);A.router.add_route('LOCK',B,A.handle_lock);A.router.add_route('UNLOCK',B,A.handle_unlock);A.parent=parent\n-\t@property\n-\tdef db(self):return self.parent.db\n-\t@property\n-\tdef services(self):return self.parent.services\n-\tasync def authenticate(C,request):\n-\t\tD='Basic ';B='user';A=request;E=A.headers.get('Authorization','')\n-\t\tif not E.startswith(D):return False\n-\t\tF=E.split(D)[1];G=base64.b64decode(F).decode();H,I=G.split(':',1);A[B]=await C.services.user.authenticate(username=H,password=I)\n-\t\ttry:A[_A]=await C.services.user.get_home_folder(A[B]['uid'])\n-\t\texcept Exception:pass\n-\t\treturn A[B]\n-\tasync def handle_get(D,request):\n-\t\tB=request\n-\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n-\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n-\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot download a directory')\n-\t\tC,F=mimetypes.guess_type(str(A));C=C or _L;return aiohttp.web.FileResponse(path=str(A),headers={_F:C},chunk_size=8192)\n-\tasync def handle_put(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];B.parent.mkdir(parents=_E,exist_ok=_E)\n-\t\tasync with aiofiles.open(B,'wb')as D:\n-\t\t\twhile(E:=await A.content.read(1024)):await D.write(E)\n-\t\treturn aiohttp.web.Response(status=201,text='File uploaded')\n-\tasync def handle_delete(C,request):\n-\t\tB=request\n-\t\tif not await C.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tA=B[_A]/B.match_info[_D]\n-\t\tif A.is_file():A.unlink();return aiohttp.web.Response(status=204)\n-\t\telif A.is_dir():shutil.rmtree(A);return aiohttp.web.Response(status=204)\n-\t\treturn aiohttp.web.Response(status=404,text='Not found')\n-\tasync def handle_mkcol(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D]\n-\t\tif B.exists():return aiohttp.web.Response(status=405,text='Directory already exists')\n-\t\tB.mkdir(parents=_E,exist_ok=_E);return aiohttp.web.Response(status=201,text='Directory created')\n-\tasync def handle_move(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];D=A[_A]/A.headers.get(_M,'').replace(_N,'')\n-\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n-\t\tshutil.move(str(B),str(D));return aiohttp.web.Response(status=201,text='Moved successfully')\n-\tasync def handle_copy(D,request):\n-\t\tA=request\n-\t\tif not await D.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];C=A[_A]/A.headers.get(_M,'').replace(_N,'')\n-\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n-\t\tif B.is_file():shutil.copy2(str(B),str(C))\n-\t\telse:shutil.copytree(str(B),str(C))\n-\t\treturn aiohttp.web.Response(status=201,text='Copied successfully')\n-\tasync def handle_options(B,request):A={'DAV':'1, 2','Allow':'OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH'};return aiohttp.web.Response(status=200,headers=A)\n-\tdef get_current_utc_time(C,filepath):\n-\t\tB=filepath\n-\t\tif B.exists():A=datetime.datetime.utcfromtimestamp(B.stat().st_mtime)\n-\t\telse:A=datetime.datetime.utcnow()\n-\t\treturn A.strftime('%Y-%m-%dT%H:%M:%SZ'),A.strftime(_P)\n-\t@time_cache_async(10)\n-\tasync def get_file_size(self,path):A=self.parent.loop;B=await A.run_in_executor(None,os.stat,path);return B.st_size\n-\t@time_cache_async(10)\n-\tasync def get_directory_size(self,directory):\n-\t\tA=0\n-\t\tfor(C,F,D)in os.walk(directory):\n-\t\t\tfor E in D:\n-\t\t\t\tB=pathlib.Path(C)/E\n-\t\t\t\tif B.exists():A+=await self.get_file_size(str(B))\n-\t\treturn A\n-\t@time_cache_async(30)\n-\tasync def get_disk_free_space(self,path='/'):B=self.parent.loop;A=await B.run_in_executor(None,os.statvfs,path);return A.f_bavail*A.f_frsize\n-\tasync def create_node(C,request,response_xml,full_path,depth):\n-\t\tif A.is_dir():etree.SubElement(Q,'{DAV:}collection')\n-\t\tR,S=C.get_current_utc_time(A);etree.SubElement(B,'{DAV:}creationdate').text=R;etree.SubElement(B,'{DAV:}quota-used-bytes').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A));etree.SubElement(B,'{DAV:}quota-available-bytes').text=str(await C.get_disk_free_space(E[_A]));etree.SubElement(B,'{DAV:}getlastmodified').text=S;etree.SubElement(B,'{DAV:}displayname').text=A.name;etree.SubElement(B,_R);T,Z=mimetypes.guess_type(A.name)\n-\t\tif A.is_file():etree.SubElement(B,'{DAV:}contenttype').text=T;etree.SubElement(B,'{DAV:}getcontentlength').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A))\n-\t\tL=etree.SubElement(B,'{DAV:}supportedlock');M=etree.SubElement(L,F);U=etree.SubElement(M,_H);etree.SubElement(U,_S);V=etree.SubElement(M,_I);etree.SubElement(V,_J);N=etree.SubElement(L,F);W=etree.SubElement(N,_H);etree.SubElement(W,'{DAV:}shared');X=etree.SubElement(N,_I);etree.SubElement(X,_J);etree.SubElement(K,'{DAV:}status').text='HTTP/1.1 200 OK'\n-\t\tif I.is_dir()and G>0:\n-\t\t\tfor Y in I.iterdir():await C.create_node(E,H,Y,G-1)\n-\tasync def handle_propfind(B,request):\n-\t\tA=request\n-\t\tif not await B.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tC=0\n-\t\ttry:C=int(A.headers.get('Depth','0'))\n-\t\texcept ValueError:pass\n-\t\tF=A.match_info.get(_D,'');D=A[_A]/F\n-\t\tif not D.exists():return aiohttp.web.Response(status=404,text='Directory not found')\n-\t\tG={'D':'DAV:'};E=etree.Element('{DAV:}multistatus',nsmap=G);await B.create_node(A,E,D,C);H=etree.tostring(E,encoding='utf-8',xml_declaration=_E).decode();return aiohttp.web.Response(status=207,text=H,content_type=_T)\n-\tasync def handle_proppatch(A,request):\n-\t\tif not await A.authenticate(request):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\treturn aiohttp.web.Response(status=207,text='PROPPATCH OK (Not Implemented)')\n-\tasync def handle_lock(A,request):\n-\t\tC=request\n-\t\tif not await A.authenticate(C):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tD=C.match_info.get(_D,'/');B=str(uuid.uuid4());A.locks[D]=B;E=await A.generate_lock_response(B);F={_U:f\"opaquelocktoken:{B}\",_F:_T};return aiohttp.web.Response(text=E,headers=F,status=200)\n-\tasync def handle_unlock(A,request):\n-\t\tB=request\n-\t\tif not await A.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tC=B.match_info.get(_D,'/');D=B.headers.get(_U,'').replace('opaquelocktoken:','')[1:-1]\n-\t\tif A.locks.get(C)==D:del A.locks[C];return aiohttp.web.Response(status=204)\n-\t\treturn aiohttp.web.Response(status=400,text='Invalid Lock Token')\n-\tasync def generate_lock_response(J,lock_id):B=lock_id;D={'D':'DAV:'};C=etree.Element(_Q,nsmap=D);E=etree.SubElement(C,_R);A=etree.SubElement(E,'{DAV:}activelock');F=etree.SubElement(A,_I);etree.SubElement(F,_J);G=etree.SubElement(A,_H);etree.SubElement(G,_S);etree.SubElement(A,'{DAV:}depth').text='Infinity';H=etree.SubElement(A,'{DAV:}owner');etree.SubElement(H,_G).text=B;etree.SubElement(A,'{DAV:}timeout').text='Infinite';I=etree.SubElement(A,'{DAV:}locktoken');etree.SubElement(I,_G).text=f\"opaquelocktoken:{B}\";return etree.tostring(C,pretty_print=_E,encoding='utf-8').decode()\n-\tdef get_last_modified(C,path):\n-\t\tif not path.exists():return\n-\t\tA=path.stat().st_mtime;B=datetime.datetime.utcfromtimestamp(A);return B.strftime(_P)\n-\tasync def handle_head(D,request):\n-\t\tB=request\n-\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n-\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n-\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot get metadata for a directory')\n-\t\tC,H=mimetypes.guess_type(str(A));C=C or _L;F=A.stat().st_size;G={_F:C,'Content-Length':str(F),'Last-Modified':D.get_last_modified(A)};return aiohttp.web.Response(status=200,headers=G)\n\\ No newline at end of file\n+    def __init__(self, parent, *args, **kwargs):\n+        middlewares = [debug_middleware]\n+\n+        super().__init__(middlewares=middlewares, *args, **kwargs)\n+        self.locks = {}\n+\n+        self.relative_url = \"/webdav\"\n+\n+        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n+        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n+        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n+        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n+        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n+        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n+        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n+        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n+        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n+        self.parent = parent\n+\n+    @property\n+    def db(self):\n+        return self.parent.db\n+\n+    @property\n+    def services(self):\n+        return self.parent.services\n+\n+    async def authenticate(self, request):\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return False\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        try:\n+            request[\"home\"] = await self.services.user.get_home_folder(\n+                request[\"user\"][\"uid\"]\n+            )\n+        except Exception:\n+            pass\n+        return request[\"user\"]\n+\n+    async def handle_get(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+\n+        return aiohttp.web.FileResponse(\n+            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n+        )\n+\n+    async def handle_put(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        file_path.parent.mkdir(parents=True, exist_ok=True)\n+        async with aiofiles.open(file_path, \"wb\") as f:\n+            while chunk := await request.content.read(1024):\n+                await f.write(chunk)\n+        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n+\n+    async def handle_delete(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        if file_path.is_file():\n+            file_path.unlink()\n+            return aiohttp.web.Response(status=204)\n+        elif file_path.is_dir():\n+            shutil.rmtree(file_path)\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=404, text=\"Not found\")\n+\n+    async def handle_mkcol(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n+        if dir_path.exists():\n+            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n+        dir_path.mkdir(parents=True, exist_ok=True)\n+        return aiohttp.web.Response(status=201, text=\"Directory created\")\n+\n+    async def handle_move(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        shutil.move(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n+\n+    async def handle_copy(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        if src_path.is_file():\n+            shutil.copy2(str(src_path), str(dest_path))\n+        else:\n+            shutil.copytree(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n+\n+    async def handle_options(self, request):\n+        headers = {\n+            \"DAV\": \"1, 2\",\n+            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n+        }\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+    def get_current_utc_time(self, filepath):\n+        if filepath.exists():\n+            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n+        else:\n+            modified_time = datetime.datetime.utcnow()\n+        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n+            \"%a, %d %b %Y %H:%M:%S GMT\"\n+        )\n+\n+    @time_cache_async(10)\n+    async def get_file_size(self, path):\n+        loop = self.parent.loop\n+        stat = await loop.run_in_executor(None, os.stat, path)\n+        return stat.st_size\n+\n+    @time_cache_async(10)\n+    async def get_directory_size(self, directory):\n+        total_size = 0\n+        for dirpath, _, filenames in os.walk(directory):\n+            for f in filenames:\n+                fp = pathlib.Path(dirpath) / f\n+                if fp.exists():\n+                    total_size += await self.get_file_size(str(fp))\n+        return total_size\n+\n+    @time_cache_async(30)\n+    async def get_disk_free_space(self, path=\"/\"):\n+        loop = self.parent.loop\n+        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n+        return statvfs.f_bavail * statvfs.f_frsize\n+\n+    async def create_node(self, request, response_xml, full_path, depth):\n+        abs_path = pathlib.Path(full_path)\n+        relative_path = str(full_path.relative_to(request[\"home\"]))\n+\n+        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n+        href_path = href_path.replace(\"./\", \"/\")\n+\n+        response = etree.SubElement(response_xml, \"{DAV:}response\")\n+        href = etree.SubElement(response, \"{DAV:}href\")\n+        href.text = href_path\n+        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+        if full_path.is_dir():\n+            etree.SubElement(res_type, \"{DAV:}collection\")\n+        creation_date, last_modified = self.get_current_utc_time(full_path)\n+        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+            await self.get_file_size(full_path)\n+            if full_path.is_file()\n+            else await self.get_directory_size(full_path)\n+        )\n+        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+            await self.get_disk_free_space(request[\"home\"])\n+        )\n+        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+        mimetype, _ = mimetypes.guess_type(full_path.name)\n+        if full_path.is_file():\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+                await self.get_file_size(full_path)\n+                if full_path.is_file()\n+                else await self.get_directory_size(full_path)\n+            )\n+        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_1, \"{DAV:}write\")\n+        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_2, \"{DAV:}write\")\n+        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+\n+        if abs_path.is_dir() and depth > 0:\n+            for item in abs_path.iterdir():\n+                await self.create_node(request, response_xml, item, depth - 1)\n+\n+    async def handle_propfind(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        depth = 0\n+        try:\n+            depth = int(request.headers.get(\"Depth\", \"0\"))\n+        except ValueError:\n+            pass\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+\n+        abs_path = request[\"home\"] / requested_path\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n+        nsmap = {\"D\": \"DAV:\"}\n+        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n+\n+        await self.create_node(request, response_xml, abs_path, depth)\n+\n+        xml_output = etree.tostring(\n+            response_xml, encoding=\"utf-8\", xml_declaration=True\n+        ).decode()\n+        return aiohttp.web.Response(\n+            status=207, text=xml_output, content_type=\"application/xml\"\n+        )\n+\n+    async def handle_proppatch(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n+\n+    async def handle_lock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_id = str(uuid.uuid4())\n+        self.locks[resource] = lock_id\n+        xml_response = await self.generate_lock_response(lock_id)\n+        headers = {\n+            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n+            \"Content-Type\": \"application/xml\",\n+        }\n+        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n+\n+    async def handle_unlock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n+            \"opaquelocktoken:\", \"\"\n+        )[1:-1]\n+        if self.locks.get(resource) == lock_token:\n+            del self.locks[resource]\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n+\n+    async def generate_lock_response(self, lock_id):\n+        nsmap = {\"D\": \"DAV:\"}\n+        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n+        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n+        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n+        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type, \"{DAV:}write\")\n+        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n+        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n+        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n+        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n+        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n+        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n+        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n+        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n+\n+    def get_last_modified(self, path):\n+        if not path.exists():\n+            return None\n+        timestamp = path.stat().st_mtime\n+        dt = datetime.datetime.utcfromtimestamp(timestamp)\n+        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n+\n+    async def handle_head(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(\n+                status=403, text=\"Cannot get metadata for a directory\"\n+            )\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+        file_size = abs_path.stat().st_size\n+\n+        headers = {\n+            \"Content-Type\": content_type,\n+            \"Content-Length\": str(file_size),\n+            \"Last-Modified\": self.get_last_modified(abs_path),\n+        }\n+\n+        return aiohttp.web.Response(status=200, headers=headers)\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex a2087be..aab17e4 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,25 +1,78 @@\n-_A=True\n-import asyncio,logging,os,asyncssh\n+import asyncio\n+import logging\n+import os\n+\n+import asyncssh\n+\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n-SFTP_ROOT='.'\n-USERNAME='test'\n-PASSWORD='woeii'\n-HOST='localhost'\n-PORT=2225\n+USERNAME = \"test\"\n+PASSWORD = \"woeii\"\n+HOST = \"localhost\"\n+PORT = 2225\n+\n+\n class MySFTPServer(asyncssh.SFTPServer):\n-\tdef __init__(A,chan):super().__init__(chan);A.root=os.path.abspath(SFTP_ROOT)\n-\tasync def stat(A,path):\"Handles 'stat' command from SFTP client\";B=os.path.join(A.root,path.lstrip('/'));return await super().stat(B)\n-\tasync def open(A,path,flags,attrs):'Handles file open requests';B=os.path.join(A.root,path.lstrip('/'));return await super().open(B,flags,attrs)\n-\tasync def listdir(A,path):'Handles directory listing';B=os.path.join(A.root,path.lstrip('/'));return await super().listdir(B)\n+    def __init__(self, chan):\n+        super().__init__(chan)\n+        self.root = os.path.abspath(SFTP_ROOT)\n+\n+    async def stat(self, path):\n+        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().stat(full_path)\n+\n+    async def open(self, path, flags, attrs):\n+        \"\"\"Handles file open requests\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().open(full_path, flags, attrs)\n+\n+    async def listdir(self, path):\n+        \"\"\"Handles directory listing\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().listdir(full_path)\n+\n+\n class MySSHServer(asyncssh.SSHServer):\n-\t'Custom SSH server to handle authentication'\n-\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n-\tdef connection_lost(A,exc):print('Client disconnected')\n-\tdef begin_auth(A,username):return _A\n-\tdef password_auth_supported(A):return _A\n-\tdef validate_password(C,username,password):A=password;B=username;print(B,A);return _A;return B==USERNAME and A==PASSWORD\n-async def start_sftp_server():os.makedirs(SFTP_ROOT,exist_ok=_A);await asyncssh.create_server(lambda:MySSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=MySFTPServer);print(f\"SFTP server running on {HOST}:{PORT}\");await asyncio.Future()\n-if __name__=='__main__':\n-\ttry:asyncio.run(start_sftp_server())\n-\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SFTP server: {e}\")\n\\ No newline at end of file\n+    \"\"\"Custom SSH server to handle authentication\"\"\"\n+\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def begin_auth(self, username):\n+\n+    def password_auth_supported(self):\n+\n+    def validate_password(self, username, password):\n+        print(username, password)\n+\n+        return True\n+        return username == USERNAME and password == PASSWORD\n+\n+\n+async def start_sftp_server():\n+\n+    await asyncssh.create_server(\n+        lambda: MySSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=MySFTPServer,\n+    )\n+    print(f\"SFTP server running on {HOST}:{PORT}\")\n+\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_sftp_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SFTP server: {e}\")\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 8879a05..2fa26a7 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,28 +1,77 @@\n-import asyncio,os,asyncssh\n-HOST='0.0.0.0'\n-PORT=2225\n-USERNAME='user'\n-PASSWORD='password'\n-SHELL='/bin/sh'\n+import asyncio\n+import os\n+\n+import asyncssh\n+\n+HOST = \"0.0.0.0\"\n+PORT = 2225\n+USERNAME = \"user\"\n+PASSWORD = \"password\"\n+\n+\n class CustomSSHServer(asyncssh.SSHServer):\n-\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n-\tdef connection_lost(A,exc):print('Client disconnected')\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(A,username,password):return username==USERNAME and password==PASSWORD\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        return username == USERNAME and password == PASSWORD\n+\n+\n async def custom_bash_process(process):\n-\t'Spawns a custom bash shell process';A=process;B=os.environ.copy();B['TERM']='xterm-256color';C=await asyncio.create_subprocess_exec(SHELL,'-i',stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE,env=B)\n-\tasync def D():\n-\t\twhile True:\n-\t\t\tB=await C.stdout.read(1)\n-\t\t\tif not B:break\n-\t\t\tA.stdout.write(B)\n-\tasync def E():\n-\t\twhile True:\n-\t\t\tB=await A.stdin.read(1)\n-\t\t\tif not B:break\n-\t\t\tC.stdin.write(B)\n-\tawait asyncio.gather(D(),E())\n-async def start_ssh_server():'Starts the AsyncSSH server with Bash';await asyncssh.create_server(lambda:CustomSSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=custom_bash_process);print(f\"SSH server running on {HOST}:{PORT}\");await asyncio.Future()\n-if __name__=='__main__':\n-\ttry:asyncio.run(start_ssh_server())\n-\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SSH server: {e}\")\n\\ No newline at end of file\n+    \"\"\"Spawns a custom bash shell process\"\"\"\n+    env = os.environ.copy()\n+    env[\"TERM\"] = \"xterm-256color\"\n+\n+    bash_proc = await asyncio.create_subprocess_exec(\n+        SHELL,\n+        \"-i\",\n+        stdin=asyncio.subprocess.PIPE,\n+        stdout=asyncio.subprocess.PIPE,\n+        stderr=asyncio.subprocess.PIPE,\n+        env=env,\n+    )\n+\n+    async def read_output():\n+        while True:\n+            data = await bash_proc.stdout.read(1)\n+            if not data:\n+                break\n+            process.stdout.write(data)\n+\n+    async def read_input():\n+        while True:\n+            data = await process.stdin.read(1)\n+            if not data:\n+                break\n+            bash_proc.stdin.write(data)\n+\n+    await asyncio.gather(read_output(), read_input())\n+\n+\n+async def start_ssh_server():\n+    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n+    await asyncssh.create_server(\n+        lambda: CustomSSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=custom_bash_process,\n+    )\n+    print(f\"SSH server running on {HOST}:{PORT}\")\n+\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_ssh_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SSH server: {e}\")\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex ef35691..4a09452 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -1,17 +1,74 @@\n-import asyncio,sys,asyncssh\n-async def handle_client(process):\n-\tA=process;E,F,C,D=A.term_size;A.stdout.write(f\"Terminal type: {A.term_type}, size: {E}x{F}\")\n-\tif C and D:A.stdout.write(f\" ({C}x{D} pixels)\")\n-\tA.stdout.write('\\nTry resizing your window!\\n')\n-\twhile not A.stdin.at_eof():\n-\t\ttry:await A.stdin.read()\n-\t\texcept asyncssh.TerminalSizeChanged as B:\n-\t\t\tA.stdout.write(f\"New window size: {B.width}x{B.height}\")\n-\t\t\tif B.pixwidth and B.pixheight:A.stdout.write(f\" ({B.pixwidth}x{B.pixheight} pixels)\")\n-\t\t\tA.stdout.write('\\n')\n-async def start_server():await asyncssh.listen('',2230,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+\n+\n+import asyncio\n+import sys\n+\n+import asyncssh\n+\n+\n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+\n+    width, height, pixwidth, pixheight = process.term_size\n+\n+    process.stdout.write(\n+        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n+    )\n+    if pixwidth and pixheight:\n+        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n+    process.stdout.write(\"\\nTry resizing your window!\\n\")\n+\n+    while not process.stdin.at_eof():\n+        try:\n+            await process.stdin.read()\n+        except asyncssh.TerminalSizeChanged as exc:\n+            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n+            if exc.pixwidth and exc.pixheight:\n+                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n+            process.stdout.write(\"\\n\")\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen(\n+        \"\",\n+        2230,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex eb4fe72..187722c 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -1,24 +1,90 @@\n-import asyncio,sys\n+\n+\n+import asyncio\n+import sys\n from typing import Optional\n-import asyncssh,bcrypt\n-passwords={'guest':b'','user':bcrypt.hashpw(b'user',bcrypt.gensalt())}\n-def handle_client(process):A=process;B=A.get_extra_info('username');A.stdout.write(f\"Welcome to my SSH server, {B}!\\n\")\n+\n+import asyncssh\n+import bcrypt\n+\n+passwords = {\n+    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n+}\n+\n+\n+def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+    username = process.get_extra_info(\"username\")\n+    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n+\n+\n class MySSHServer(asyncssh.SSHServer):\n-\tdef connection_made(B,conn):A=conn.get_extra_info('peername')[0];print(f\"SSH connection received from {A}.\")\n-\tdef connection_lost(A,exc):\n-\t\tif exc:print('SSH connection error: '+str(exc),file=sys.stderr)\n-\t\telse:print('SSH connection closed.')\n-\tdef begin_auth(A,username):return passwords.get(username)!=b''\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(D,username,password):\n-\t\tA=password;B=username\n-\t\tif B not in passwords:return False\n-\t\tC=passwords[B]\n-\t\tif not A and not C:return True\n-\t\treturn bcrypt.checkpw(A.encode('utf-8'),C)\n-async def start_server():await asyncssh.create_server(MySSHServer,'',2231,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n+        peername = conn.get_extra_info(\"peername\")[0]\n+        print(f\"SSH connection received from {peername}.\")\n+\n+    def connection_lost(self, exc: Optional[Exception]) -> None:\n+        if exc:\n+            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n+        else:\n+            print(\"SSH connection closed.\")\n+\n+    def begin_auth(self, username: str) -> bool:\n+        return passwords.get(username) != b\"\"\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        if username not in passwords:\n+            return False\n+        pw = passwords[username]\n+        if not password and not pw:\n+            return True\n+        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.create_server(\n+        MySSHServer,\n+        \"\",\n+        2231,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex 39f45e3..cfd5d21 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -1,28 +1,112 @@\n-import asyncio,sys\n-from typing import List,cast\n+\n+\n+import asyncio\n+import sys\n+from typing import List, cast\n+\n import asyncssh\n+\n+\n class ChatClient:\n-\t_clients:List['ChatClient']=[]\n-\tdef __init__(A,process):A._process=process\n-\t@classmethod\n-\tasync def handle_client(A,process):await A(process).run()\n-\tasync def readline(A):return cast(str,A._process.stdin.readline())\n-\tdef write(A,msg):A._process.stdout.write(msg)\n-\tdef broadcast(A,msg):\n-\t\tfor B in A._clients:\n-\t\t\tif B!=A:B.write(msg)\n-\tdef begin_auth(A,username):return True\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(A,username,password):return True\n-\tasync def run(A):\n-\t\tA.write('Welcome to chat!\\n\\n');A.write('Enter your name: ');B=(await A.readline()).rstrip('\\n');A.write(f\"\\n{len(A._clients)} other users are connected.\\n\\n\");A._clients.append(A);A.broadcast(f\"*** {B} has entered chat ***\\n\")\n-\t\ttry:\n-\t\t\tasync for C in A._process.stdin:A.broadcast(f\"{B}: {C}\")\n-\t\texcept asyncssh.BreakReceived:pass\n-\t\tA.broadcast(f\"*** {B} has left chat ***\\n\");A._clients.remove(A)\n-async def start_server():await asyncssh.listen('',2235,server_host_keys=['ssh_host_key'],process_factory=ChatClient.handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+    _clients: List[\"ChatClient\"] = []\n+\n+    def __init__(self, process: asyncssh.SSHServerProcess):\n+        self._process = process\n+\n+    @classmethod\n+    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n+        await cls(process).run()\n+\n+    async def readline(self) -> str:\n+        return cast(str, self._process.stdin.readline())\n+\n+    def write(self, msg: str) -> None:\n+        self._process.stdout.write(msg)\n+\n+    def broadcast(self, msg: str) -> None:\n+        for client in self._clients:\n+            if client != self:\n+                client.write(msg)\n+\n+    def begin_auth(self, username: str) -> bool:\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        return True\n+\n+    async def run(self) -> None:\n+        self.write(\"Welcome to chat!\\n\\n\")\n+\n+        self.write(\"Enter your name: \")\n+        name = (await self.readline()).rstrip(\"\\n\")\n+\n+        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n+\n+        self._clients.append(self)\n+        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n+\n+        try:\n+            async for line in self._process.stdin:\n+                self.broadcast(f\"{name}: {line}\")\n+        except asyncssh.BreakReceived:\n+            pass\n+\n+        self.broadcast(f\"*** {name} has left chat ***\\n\")\n+        self._clients.remove(self)\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen(\n+        \"\",\n+        2235,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=ChatClient.handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Update dependencies and refactor repository view for improved navigation and file handling", "commit": "44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8", "diff": "commit 44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 15:55:51 2025 +0200\n\n    Update.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex b6f1688..cc84391 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,7 +33,8 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n-    \"uvloop\"\n+    \"uvloop\",\n+    \"humanize\"\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ceb7c9d..3e4ad5e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -182,8 +182,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repository}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repository}/{path:.*}\", RepositoryView)\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nindex f7a2e9d..0c19142 100644\n--- a/src/snek/view/repository.py\n+++ b/src/snek/view/repository.py\n@@ -1,15 +1,265 @@\n-from snek.system.view import BaseView\n+import os\n+import mimetypes\n+import urllib.parse\n+from pathlib import Path\n+import humanize\n from aiohttp import web\n+from snek.system.view import BaseView\n+import asyncio \n+from git import Repo\n+\n+\n+\n+\n+class BareRepoNavigator:\n+    def __init__(self, repo_path):\n+        \"\"\"Initialize the navigator with a bare repository path.\"\"\"\n+        try:\n+            self.repo = Repo(repo_path)\n+            if not self.repo.bare:\n+                print(f\"Error: {repo_path} is not a bare repository.\")\n+                sys.exit(1)\n+        except git.exc.InvalidGitRepositoryError:\n+            print(f\"Error: {repo_path} is not a valid Git repository.\")\n+            sys.exit(1)\n+        except Exception as e:\n+            print(f\"Error opening repository: {str(e)}\")\n+            sys.exit(1)\n+            \n+        self.repo_path = repo_path\n+        self.branches = list(self.repo.branches)\n+        self.current_branch = None\n+        self.current_commit = None\n+        self.current_path = \"\"\n+        self.history = []\n+        \n+    def get_branches(self):\n+        \"\"\"Return a list of branch names in the repository.\"\"\"\n+        return [branch.name for branch in self.branches]\n+    \n+    def set_branch(self, branch_name):\n+        \"\"\"Set the current branch.\"\"\"\n+        try:\n+            self.current_branch = self.repo.branches[branch_name]\n+            self.current_commit = self.current_branch.commit\n+            self.current_path = \"\"\n+            self.history = []\n+            return True\n+        except IndexError:\n+            return False\n+            \n+    def get_commits(self, count=10):\n+        \"\"\"Get the latest commits on the current branch.\"\"\"\n+        if not self.current_branch:\n+            return []\n+            \n+        commits = []\n+        for commit in self.repo.iter_commits(self.current_branch, max_count=count):\n+            commits.append({\n+                'hash': commit.hexsha,\n+                'short_hash': commit.hexsha[:7],\n+                'message': commit.message.strip(),\n+                'author': commit.author.name,\n+                'date': datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M:%S')\n+            })\n+        return commits\n+        \n+    def set_commit(self, commit_hash):\n+        \"\"\"Set the current commit by hash.\"\"\"\n+        try:\n+            self.current_commit = self.repo.commit(commit_hash)\n+            self.current_path = \"\"\n+            self.history = []\n+            return True\n+        except ValueError:\n+            return False\n+            \n+    def list_directory(self, path=\"\"):\n+        \"\"\"List the contents of a directory in the current commit.\"\"\"\n+        if not self.current_commit:\n+            return {'dirs': [], 'files': []}\n+            \n+        dirs = []\n+        files = []\n+        \n+        try:\n+            if path:\n+                tree = self.current_commit.tree[path]\n+                    return {'dirs': [], 'files': [path]}\n+            else:\n+                tree = self.current_commit.tree\n+                \n+            for item in tree:\n+                if item.type == 'tree':\n+                    item_path = os.path.join(path, item.name) if path else item.name\n+                    dirs.append(item_path)\n+                elif item.type == 'blob':\n+                    item_path = os.path.join(path, item.name) if path else item.name\n+                    files.append(item_path)\n+                    \n+            dirs.sort()\n+            files.sort()\n+            return {'dirs': dirs, 'files': files}\n+            \n+        except KeyError:\n+            return {'dirs': [], 'files': []}\n+            \n+    def get_file_content(self, file_path):\n+        \"\"\"Get the content of a file in the current commit.\"\"\"\n+        if not self.current_commit:\n+            return None\n+            \n+        try:\n+            blob = self.current_commit.tree[file_path]\n+            return blob.data_stream.read().decode('utf-8', errors='replace')\n+        except (KeyError, UnicodeDecodeError):\n+            try:\n+                blob = self.current_commit.tree[file_path]\n+                return blob.data_stream.read()\n+            except:\n+                return None\n+                \n+    def navigate_to(self, path):\n+        \"\"\"Navigate to a specific path, updating the current path.\"\"\"\n+        if not self.current_commit:\n+            return False\n+            \n+        try:\n+            if path:\n+            self.history.append(self.current_path)\n+            self.current_path = path\n+            return True\n+        except KeyError:\n+            return False\n+            \n+    def navigate_back(self):\n+        \"\"\"Navigate back to the previous path.\"\"\"\n+        if self.history:\n+            self.current_path = self.history.pop()\n+            return True\n+        return False\n+\n+\n+\n class RepositoryView(BaseView):\n-\tasync def get(A):\n-\t\tG='type';H='name';I='.git';J='username';B=A.request.match_info[J];K=A.request.match_info['repo_name'];C=A.request.match_info.get('rel_path','')\n-\t\tif not B.count('-')==4:E=await A.services.user.get_by_username(B)\n-\t\telse:E=await A.services.user.get(B)\n-\t\tif not E:return web.HTTPNotFound()\n-\t\tB=E[J];M=await A.services.user.get_repository_path(E['uid'])\n-\t\tif C.endswith(I):C=C[:-4]\n-\t\tL=M.joinpath(K+I)\n-\t\tif not L.exists():return web.HTTPNotFound()\n-\t\timport os;from git import Repo;N=Repo(L.joinpath(C));F=[];O=[];P=N.head.commit\n-\t\tfor D in P.tree.traverse():F.append({H:D.name,'mode':D.mode,G:D.type,'path':D.path,'size':D.size})\n-\t\tsorted(F,key=lambda x:x[H]);sorted(F,key=lambda x:x[G],reverse=True);Q=f\"{B}/{C}\"[:-4];return await A.render_template('repository.html',dict(username=B,repo_name=K,rel_path=C,full_path=Q,files=F,directories=O))\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    def checkout_bare_repo(self, bare_repo_path: Path, target_path: Path, ref: str = 'HEAD'):\n+        repo = Repo(bare_repo_path)\n+        assert repo.bare, \"Repository is not bare.\"\n+\n+        commit = repo.commit(ref)\n+        tree = commit.tree\n+\n+        for blob in tree.traverse():\n+            target_file = target_path / blob.path\n+                \n+            target_file.parent.mkdir(parents=True, exist_ok=True)\n+            print(blob.path)\n+\n+            with open(target_file, 'wb') as f:\n+                f.write(blob.data_stream.read())\n+\n+\n+    async def get(self):\n+\n+        base_repo_path = Path(\"drive/repositories\")  \n+\n+        authenticated_user_id = self.session.get(\"uid\")\n+\n+        username = self.request.match_info.get('username')\n+        repo_name = self.request.match_info.get('repository')\n+        rel_path = self.request.match_info.get('path', '')\n+        user = None\n+        if not username.count(\"-\") == 4:\n+            user = await self.app.services.user.get(username=username)\n+            if not user:\n+                return web.Response(text=\"404 Not Found\", status=404)\n+            username = user[\"username\"]\n+        else:\n+            user = await self.app.services.user.get(uid=username)\n+\n+        repo = await self.app.services.repository.get(name=repo_name, user_uid=user[\"uid\"])\n+        if not repo:\n+            return web.Response(text=\"404 Not Found\", status=404)\n+        if repo['is_private'] and authenticated_user_id != repo['uid']:  \n+            return web.Response(text=\"404 Not Found\", status=404) \n+\n+        repo_root_base = (base_repo_path / user['uid'] / (repo_name + \".git\")).resolve()\n+        repo_root = (base_repo_path / user['uid'] / repo_name).resolve()\n+        try:\n+            loop = asyncio.get_event_loop()\n+            await loop.run_in_executor(None,\n+                         self.checkout_bare_repo, repo_root_base, repo_root\n+            )\n+        except:\n+            pass\n+        \n+        if not repo_root.exists() or not repo_root.is_dir():\n+            return web.Response(text=\"404 Not Found\", status=404)\n+\n+        safe_rel_path = os.path.normpath(rel_path).lstrip(os.sep)\n+        abs_path = (repo_root / safe_rel_path).resolve()\n+\n+        if not abs_path.exists() or not abs_path.is_relative_to(repo_root):\n+            return web.Response(text=\"404 Not Found\", status=404)\n+\n+        if abs_path.is_dir():\n+            return web.Response(text=self.render_directory(abs_path, username, repo_name, safe_rel_path), content_type='text/html')\n+        else:\n+            return web.Response(text=self.render_file(abs_path), content_type='text/html')\n+\n+    def render_directory(self, abs_path, username, repo_name, safe_rel_path):\n+        entries = sorted(abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))\n+        items = []\n+\n+        if safe_rel_path:\n+            parent_path = Path(safe_rel_path).parent\n+            parent_link = f\"/repository/{username}/{repo_name}/{parent_path}\".rstrip('/')\n+            items.append(f'<li><a href=\"{parent_link}\">\u2b05\ufe0f ..</a></li>')\n+\n+        for entry in entries:\n+            link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))\n+            link = f\"/repository/{username}/{repo_name}/{link_path}\".rstrip('/')\n+            display = entry.name + ('/' if entry.is_dir() else '')\n+            size = '' if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)\n+            icon = self.get_icon(entry)\n+            items.append(f'<li>{icon} <a href=\"{link}\">{display}</a> {size}</li>')\n+\n+        html = f\"\"\"\n+        <html>\n+        <head><title>\ud83d\udcc1 {repo_name}/{safe_rel_path}</title></head>\n+        <body>\n+        <h2>\ud83d\udcc1 {username}/{repo_name}/{safe_rel_path}</h2>\n+        <ul>\n+            {''.join(items)}\n+        </ul>\n+        </body>\n+        </html>\n+        \"\"\"\n+        return html\n+\n+    def render_file(self, abs_path):\n+        try:\n+            with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:\n+                content = f.read()\n+            return f\"<pre>{content}</pre>\"\n+        except Exception as e:\n+            return f\"<h1>Error</h1><pre>{e}</pre>\"\n+\n+    def get_icon(self, file):\n+        if file.is_dir(): return \"\ud83d\udcc1\"\n+        mime = mimetypes.guess_type(file.name)[0] or ''\n+        if mime.startswith(\"image\"): return \"\ud83d\uddbc\ufe0f\"\n+        if mime.startswith(\"text\"): return \"\ud83d\udcc4\"\n+        if mime.startswith(\"audio\"): return \"\ud83c\udfb5\"\n+        if mime.startswith(\"video\"): return \"\ud83c\udfac\"\n+        if file.name.endswith(\".py\"): return \"\ud83d\udc0d\"\n+        return \"\ud83d\udce6\"\n+"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added DBService and RPCView db methods", "commit": "dd108c20044540c3801ac461c612392bed76ff89", "diff": "commit dd108c20044540c3801ac461c612392bed76ff89\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 17:37:53 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex be356dc..a81b9e7 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -13,7 +13,7 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n-\n+from snek.service.db import DBService\n \n @functools.cache\n def get_services(app):\n@@ -31,6 +31,7 @@ def get_services(app):\n             \"drive_item\": DriveItemService(app=app),\n             \"user_property\": UserPropertyService(app=app),\n             \"repository\": RepositoryService(app=app),\n+            \"db\": DBService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/db.py b/src/snek/service/db.py\nnew file mode 100644\nindex 0000000..fe9fb8a\n--- /dev/null\n+++ b/src/snek/service/db.py\n@@ -0,0 +1,71 @@\n+from snek.system.service import BaseService\n+import dataset \n+import uuid \n+\n+from datetime import datetime \n+\n+class DBService(BaseService):\n+    \n+    async def get_db(self, user_uid):\n+        \n+        home_folder = await self.app.services.user.get_home_folder(user_uid)\n+        home_folder.mkdir(parents=True, exist_ok=True)\n+        db_path = home_folder.joinpath(\"snek/user.db\")\n+        db_path.parent.mkdir(parents=True, exist_ok=True)\n+    \n+    async def insert(self, user_uid, table_name, values):\n+        db = await self.get_db(user_uid)\n+        return db[table_name].insert(values)\n+        \n+\n+    async def update(self, user_uid, table_name, values, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        if not values:\n+            return False\n+        return db[table_name].update(values, filters)\n+\n+    async def upsert(self, user_uid, table_name, values, keys):\n+        db = await self.get_db(user_uid)\n+        return db[table_name].upsert(values, keys)\n+\n+    async def find(self, user_uid, table_name, kwargs):\n+        db = await self.get_db(user_uid)\n+        kwargs['_limit'] = kwargs.get('_limit', 30)\n+        return [dict(row) for row in db[table_name].find(**kwargs)]\n+\n+    async def get(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        try:\n+            return dict(db[table_name].find_one(**filters))\n+        except ValueError:\n+            return None\n+\n+\n+    async def delete(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return db[table_name].delete(**filters)\n+\n+    async def query(self, sql,values):\n+        db = await self.app.db\n+        return [dict(row) for row in db.query(sql, values or {})]\n+\n+    async def exists(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return bool(db[table_name].find_one(**filters))\n+\n+    \n+\n+    async def count(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return db[table_name].count(**filters)\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3161f49..4592f21 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -26,6 +26,31 @@ class RPCView(BaseView):\n             self.services = self.app.services\n             self.ws = ws\n \n+        async def db_insert(self, table_name, record):\n+            self._require_login()\n+\n+            return await self.services.db.insert(self.user_uid, table_name, record)\n+        async def db_update(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.update(self.user_uid, table_name, record)\n+        async def db_delete(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.delete(self.user_uid, table_name, record)\n+        async def db_get(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.get(self.user_uid, table_name, record)\n+        async def db_find(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.find(self.user_uid, table_name, record)\n+        async def db_upsert(self, table_name, record,keys):\n+            self._require_login()\n+            return await self.services.db.upsert(self.user_uid, table_name, record,keys)\n+\n+        async def db_query(self, table_name, args):\n+            self._require_login()\n+            return await self.services.db.query(self.user_uid, table_name, sql, args)\n+\n+\n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added Drive view and API endpoints", "commit": "f0591d493955d9c126e7dee6d1a06917c48bbbd2", "diff": "commit f0591d493955d9c126e7dee6d1a06917c48bbbd2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 15:03:50 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3e4ad5e..362d519 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -33,6 +33,7 @@ from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.avatar import AvatarView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.drive import DriveView\n+from snek.view.drive import DriveApiView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n@@ -178,7 +179,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n         self.router.add_view(\"/terminal.html\", TerminalView)\n-        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive.json\", DriveApiView)\n+        self.router.add_view(\"/drive.html\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\ndiff --git a/src/snek/static/file-manager.js b/src/snek/static/file-manager.js\nindex 55dbac6..b0d9765 100644\n--- a/src/snek/static/file-manager.js\n+++ b/src/snek/static/file-manager.js\n@@ -9,6 +9,7 @@ class FileBrowser extends HTMLElement {\n   }\n \n   connectedCallback() {\n+      this.path = this.getAttribute(\"path\") || \"\";\n     this.renderShell();\n     this.load();\n   }\n@@ -19,11 +20,11 @@ class FileBrowser extends HTMLElement {\n       <style>\n         :host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }\n         nav    { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }\n         .crumb  { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n         .grid   { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }\n         .tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }\n         img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }\n         .icon { font-size:48px; line-height:90px; }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 6b74792..a373c2d 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -35,6 +35,7 @@\n     <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n     <nav class=\"no-select\" style=\"overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/drive.html\">\ud83d\udcc2</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\ndiff --git a/src/snek/templates/drive.html b/src/snek/templates/drive.html\nnew file mode 100644\nindex 0000000..4a80d60\n--- /dev/null\n+++ b/src/snek/templates/drive.html\n@@ -0,0 +1,9 @@\n+{% extends \"app.html\" %}\n+\n+{% block header_text %}Drive{% endblock %}\n+\n+{% block main %}\n+<div class=\"container\">\n+<file-manager path=\"{{path}}\" style=\"flex: 1\"></file-manager>\n+</div>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/create.html b/src/snek/templates/settings/repositories/create.html\nindex cfef080..ce3eacb 100644\n--- a/src/snek/templates/settings/repositories/create.html\n+++ b/src/snek/templates/settings/repositories/create.html\n@@ -3,43 +3,7 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-plus\"></i> Create Repository</h1>{% endblock %}\n \n {% block main %}\n-\n-<style>\n-.container {\n-    div,input,label,button{\n-        padding-bottom: 15px;\n-    }\n-}\n-    form {\n-      padding: 2rem;\n-      border-radius: 10px;\n-    }\n-    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n-    input[type=\"text\"] {\n-      padding: 0.5rem;\n-      font-size: 1rem;\n-    }\n-\n-\n-button, a.button {\n-      padding: 0.1rem 0.8rem; text-decoration: none; cursor: pointer;\n-      transition: background 0.2s;\n-      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n-    }\n-    .\n-    .cancel {\n-    }\n-    @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      form { padding: 1rem; }\n-    }\n-  </style>\n-</head>\n-<body>\n+{% include 'settings/repositories/form.html' %}\n   <div class=\"container\">\n     <form action=\"/settings/repositories/create.html\" method=\"post\">\n       <div>\n@@ -52,8 +16,9 @@ button, a.button {\n           <i class=\"fa-solid fa-lock\"></i> Private\n         </label>\n       </div>\n-      <button type=\"submit\"><i class=\"fa-solid fa-plus\"></i> Create</button>\n-      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Back</button> \n+      <button type=\"submit\"><i class=\"fa-solid fa-pen\"></i> Update</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i>Cancel</button>\n+\n     </form>\n   </div>\n   {% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nindex 5ba6c5b..d06003b 100644\n--- a/src/snek/templates/settings/repositories/delete.html\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -3,33 +3,8 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>{% endblock %}\n \n {% block main %}\n-  <style>\n-    .repo-name {\n-      font-weight: bold;\n-      font-size: 1.2rem;\n-      margin: 1rem 0;\n-    }\n-    .actions {\n-      display: flex; gap: 1rem; justify-content: left; margin-top: 1.5rem;\n-    }\n-    button {\n-      border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n-      font-size: 1rem; cursor: pointer;\n-      display: flex; align-items: center; gap: 0.5rem; text-decoration: none; justify-content: center;\n-      transition: background 0.2s;\n-    }\n-    .cancel {\n-    }\n-    @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      .confirm-box { padding: 1rem; }\n-    }\n-  </style>\n-  <div class=\"container\">\n+    {% include \"settings/repositories/form.html\" %}  \n+<div class=\"container\">\n       <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n       <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repository.name }}</div>\n       <form method=\"post\" style=\"margin-top:1.5rem;\">\ndiff --git a/src/snek/templates/settings/repositories/form.html b/src/snek/templates/settings/repositories/form.html\nnew file mode 100644\nindex 0000000..8785cc6\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/form.html\n@@ -0,0 +1,28 @@\n+  <style>\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+      div {\n+        padding: 10px;\n+        padding-bottom: 15px\n+      }\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    button {\n+      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n+      cursor: pointer;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .cancel {\n+    }\n+      @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+      }\n+\n+    </style>\n+\n+\ndiff --git a/src/snek/templates/settings/repositories/update.html b/src/snek/templates/settings/repositories/update.html\nindex 5168c92..93c9f72 100644\n--- a/src/snek/templates/settings/repositories/update.html\n+++ b/src/snek/templates/settings/repositories/update.html\n@@ -3,28 +3,8 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-pen\"></i> Update Repository</h1>{% endblock %}\n \n {% block main %}\n-  <style>\n-    form {\n-      padding: 2rem;\n-      border-radius: 10px;\n-    }\n-    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n-    button {\n-      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n-      cursor: pointer;\n-      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n-    }\n-    .cancel {\n-    }\n-      @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      form { padding: 1rem; }\n-    }\n-  </style>\n-  <div class=\"container\">\n+{% include \"settings/repositories/form.html\" %}\n+    <div class=\"container\">\n     <form method=\"post\">\n       <!-- Assume hidden id for backend use -->\n       <input type=\"hidden\" name=\"id\" value=\"{{ repository.id }}\">\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex e3c3343..d6de50f 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -11,47 +11,42 @@ from datetime import datetime\n \n \n \n-\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n-\"\"\"\n from aiohttp import web\n from pathlib import Path\n import mimetypes, urllib.parse\n \n-BASE_DIR   = Path(__file__).parent.resolve()\n-ROOT_DIR.mkdir(exist_ok=True)\n-ASSETS_DIR.mkdir(exist_ok=True)\n+class DriveView(BaseView):\n \n+    async def get(self):\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        if rel_path:\n+            target = target.joinpath(rel_path)\n \n-def safe_resolve_path(rel: str) -> Path:\n-    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n-    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n-    if target == ROOT_DIR or ROOT_DIR in target.parents:\n-        return target\n-    raise FileNotFoundError(\"Unsafe path\")\n+        if not target.exists():\n+            return web.HTTPNotFound(reason=\"Path not found\")\n \n+        if target.is_dir():\n+            return await self.render_template(\"drive.html\",{\"path\": rel_path})\n+        if target.is_file():\n+            return web.FileResponse(target)\n \n-class DriveView(BaseView):\n+\n+class DriveApiView(BaseView):\n     async def get(self):\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n         rel = self.request.query.get(\"path\", \"\")\n         offset = int(self.request.query.get(\"offset\", 0))\n         limit  = int(self.request.query.get(\"limit\", 20))\n-        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n         if rel:\n-            target.joinpath(rel)\n+            target = target.joinpath(rel)\n \n         if not target.exists():\n             return web.json_response({\"error\": \"Not found\"}, status=404)\n \n         if target.is_dir():\n             entries = []\n             for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n                 item_path = (Path(rel) / p.name).as_posix()\n                 mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n@@ -73,10 +68,6 @@ class DriveView(BaseView):\n                 \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n             })\n         \n-        with open(target, \"rb\") as f:\n-            content = f.read()\n-            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n         url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n         return web.json_response({\n             \"name\": target.name,"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added typing indicator and glow effect for active users", "commit": "3412aa0bf0c0bb138c234f88fad55cb69267df79", "diff": "commit 3412aa0bf0c0bb138c234f88fad55cb69267df79\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 15:08:28 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 0517ff9..2603893 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -151,7 +151,13 @@ export class App extends EventHandler {\n     ws = null;\n     rpc = null;\n     audio = null;\n-    user = {};\n+    user = {}; \n+    typeLock = null;\n+    typeListener = null\n+    typeEventChannelUid = null\n+    async set_typing(channel_uid){\n+    \tthis.typeEventChannel_uid = channel_uid\n+    }\n \n     async ping(...args) {\n         if (this.is_pinging) return false\n@@ -173,16 +179,25 @@ export class App extends EventHandler {\n         this.ping_interval = setInterval(() => {\n             this.ping(\"active\")\n         }, 15000)\n-\n+\tthis.typeEventChannelUid = null\n+\tthis.typeListener = setInterval(()=>{\n+\t\tif(this.typeEventChannelUid){\n+\t\t\tthis.rpc.set_typing(this.typeEventChannelUid)\n+\t\t\tthis.typeEventChannelUid = null\n+\t\t}\n+\t})\n \n         const me = this\n         this.ws.addEventListener(\"connected\", (data) => {\n             this.ping(\"online\")\n         })\n+\t\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(\"channel-message\", data);\n         });\n-\n+\tthis.ws.addEventListener(\"event\",(data)=>{\n+\t\tconsole.info(\"aaaa\")\t\n+\t})\n         this.rpc.getUser(null).then(user => {\n             me.user = user;\n         });\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 27153ee..90f20b4 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -366,6 +366,24 @@ a {\n }\n \n+@keyframes glow {\n+    0% {\n+    }\n+    50% {\n+    }\n+    100% {\n+    }\n+}\n+\n+.glow {\n+    animation: glow 1s;\n+}\n+\n+\n+\n @media only screen and (max-width: 768px) {\n     \n     header{\ndiff --git a/src/snek/static/socket.js b/src/snek/static/socket.js\nindex 83f6cac..78363be 100644\n--- a/src/snek/static/socket.js\n+++ b/src/snek/static/socket.js\n@@ -81,8 +81,13 @@ export class Socket extends EventHandler {\n         }\n         if (data.channel_uid) {\n             this.emit(data.channel_uid, data.data);\n+\t    if(!data['event'])\n             this.emit(\"channel-message\", data);\n         }\n+\tthis.emit(\"data\", data.data)\n+\tif(data['event']){\n+\t    this.emit(data.event, data)\n+\t}\n     }\n \n     disconnect() {\n@@ -134,4 +139,4 @@ export class Socket extends EventHandler {\n             me.sendJson(call);\n         });\n     }\n-}\n\\ No newline at end of file\n+}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3b25001..5a5cc2c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -24,7 +24,7 @@\n \n <script type=\"module\">\n     import { app } from \"/app.js\";\n-\n+    import { Schedule } from \"/schedule.js\";\n     const channelUid = \"{{ channel.uid.value }}\";\n \n     function getInputField(){\n@@ -40,7 +40,9 @@\n                     app.rpc.sendMessage(channelUid, message);\n                     e.target.value = '';\n                 }\n-            }\n+\t    }else{\n+\t\tapp.rpc.set_typing(channelUid)\n+\t    }\n         });\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n             getInputField().focus();\n@@ -74,6 +76,30 @@\n             }\n         });\n \n+\t function triggerGlow(uid) {\n+\t \tdocument.querySelectorAll(\".avatar\").forEach((el)=>{\n+\t\t   const div = el.closest('a');\n+\t\t   if(el.href.indexOf(uid)!=-1){\n+\t\t\tel.classList.add('glow')\n+\t\t    \tlet originalColor = el.style.backgroundColor \n+\t\t\tsetTimeout(()=>{\n+\t\t\t\tel.classList.remove('glow')\n+\t\t\t},1200)\n+\t\t    }\n+\n+\t      })\n+ \t\n+    \t}\n+\tapp.ws.addEventListener(\"set_typing\",(data)=>{\n+\t\ttriggerGlow(data.data.user_uid)\t\n+\n+\t})\n+\t\t\n+\n         const chatInput = document.querySelector(\".chat-area\")\n         chatInput.addEventListener(\"drop\", async (e) => {\n             e.preventDefault();\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 4592f21..645ccbc 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -33,6 +33,21 @@ class RPCView(BaseView):\n         async def db_update(self, table_name, record):\n             self._require_login()\n             return await self.services.db.update(self.user_uid, table_name, record)\n+        async def set_typing(self,channel_uid):\n+            self._require_login()\n+            user = await self.services.user.get(self.user_uid)\n+            return await self.services.socket.broadcast(channel_uid, {\n+                \"channel_uid\": \"293ecf12-08c9-494b-b423-48ba1a2d12c2\",\n+                \"event\": \"set_typing\",\n+                \"data\": {\n+                    \"event\":\"set_typing\",\n+                    \"user_uid\": user['uid'],\n+                    \"username\": user[\"username\"],\n+                    \"nick\": user[\"nick\"],\n+                    \"channel_uid\": channel_uid\n+                }\n+            })\n+\n         async def db_delete(self, table_name, record):\n             self._require_login()\n             return await self.services.db.delete(self.user_uid, table_name, record)"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added channel attachment functionality with file uploads and views", "commit": "9133b7c3ce6457fa6c218b540828c752b4ba5c72", "diff": "commit 9133b7c3ce6457fa6c218b540828c752b4ba5c72\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 20:38:32 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 362d519..0a6b018 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -53,6 +53,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n+from snek.view.channel import ChannelAttachmentView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n \n@@ -175,6 +176,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel_uid}/attachment.bin\",ChannelAttachmentView)\n+        self.router.add_view(\"/channel/attachment/{relative_url:.*}\",ChannelAttachmentView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex ab7904f..5428f10 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -9,6 +9,7 @@ from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n+from snek.mapper.channel_attachment import ChannelAttachmentMapper\n from snek.system.object import Object\n \n \n@@ -25,6 +26,7 @@ def get_mappers(app=None):\n             \"drive\": DriveMapper(app=app),\n             \"user_property\": UserPropertyMapper(app=app),\n             \"repository\": RepositoryMapper(app=app),\n+            \"channel_attachment\": ChannelAttachmentMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/channel_attachment.py b/src/snek/mapper/channel_attachment.py\nnew file mode 100644\nindex 0000000..0d6e404\n--- /dev/null\n+++ b/src/snek/mapper/channel_attachment.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_attachment import ChannelAttachmentModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelAttachmentMapper(BaseMapper):\n+    table_name = \"channel_attachment\"\n+    model_class = ChannelAttachmentModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 6399c89..17832c6 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -11,6 +11,7 @@ from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n+from snek.model.channel_attachment import ChannelAttachmentModel\n from snek.system.object import Object\n \n \n@@ -27,6 +28,7 @@ def get_models():\n             \"notification\": NotificationModel,\n             \"user_property\": UserPropertyModel,\n             \"repository\": RepositoryModel,\n+            \"channel_attachment\": ChannelAttachmentModel,\n         }\n     )\n \ndiff --git a/src/snek/model/channel_attachment.py b/src/snek/model/channel_attachment.py\nnew file mode 100644\nindex 0000000..9add1b8\n--- /dev/null\n+++ b/src/snek/model/channel_attachment.py\n@@ -0,0 +1,16 @@\n+from snek.system.model import BaseModel\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class ChannelAttachmentModel(BaseModel):\n+\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    size = ModelField(name=\"size\", required=False, kind=int)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    mime_type = ModelField(name=\"type\", required=True, kind=str)\n+    relative_url = ModelField(name=\"relative_url\", required=True, kind=str)\n+    resource_type = ModelField(name=\"resource_type\", required=True, kind=str,value=\"file\")\n+\n+\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex a81b9e7..dae9e09 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -12,6 +12,7 @@ from snek.service.user import UserService\n from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n+from snek.service.channel_attachment import ChannelAttachmentService\n from snek.system.object import Object\n from snek.service.db import DBService\n \n@@ -32,6 +33,7 @@ def get_services(app):\n             \"user_property\": UserPropertyService(app=app),\n             \"repository\": RepositoryService(app=app),\n             \"db\": DBService(app=app),\n+            \"channel_attachment\": ChannelAttachmentService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex b90e66f..f2288cb 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -3,10 +3,19 @@ from datetime import datetime\n from snek.system.model import now\n from snek.system.service import BaseService\n \n+import pathlib \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n+    async def get_attachment_folder(self, channel_uid,ensure=False):\n+        path = pathlib.Path(f\"./drive/{channel_uid}/attachments\")\n+        if ensure:\n+            path.mkdir(\n+                parents=True, exist_ok=True\n+            )\n+        return path\n+\n     async def get(self, uid=None, **kwargs):\n         if uid:\n             kwargs[\"uid\"] = uid\ndiff --git a/src/snek/service/channel_attachment.py b/src/snek/service/channel_attachment.py\nnew file mode 100644\nindex 0000000..d225a7b\n--- /dev/null\n+++ b/src/snek/service/channel_attachment.py\n@@ -0,0 +1,25 @@\n+from snek.system.service import BaseService\n+import urllib.parse \n+import pathlib \n+import mimetypes\n+import uuid\n+\n+class ChannelAttachmentService(BaseService):\n+    mapper_name=\"channel_attachment\"\n+\n+    async def create_file(self, channel_uid, user_uid, name):\n+        attachment = await self.new()\n+        attachment[\"channel_uid\"] = channel_uid\n+        attachment['user_uid'] = user_uid\n+        attachment[\"name\"] = name\n+        attachment[\"mime_type\"] = mimetypes.guess_type(name)[0]\n+        attachment['resource_type'] = \"file\"\n+        real_file_name = f\"{attachment['uid']}-{name}\"\n+        attachment[\"relative_url\"] = urllib.parse.quote(f\"{attachment['uid']}/{name}\")  \n+        attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)\n+        attachment_path = attachment_folder.joinpath(real_file_name)\n+        attachment[\"path\"] = str(attachment_path)\n+        if await self.save(attachment):\n+            return attachment\n+        raise Exception(f\"Failed to create channel attachment: {attachment.errors}.\")\n+\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 06563c9..4fc2994 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -21,13 +21,13 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n-        formData.append('channel_uid', this.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n-\n         const request = new XMLHttpRequest();\n-        request.open('POST', '/drive.bin', true);\n+\n+        request.responseType = 'json';\n+        request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true);\n \n         request.upload.onprogress = function (event) {\n             if (event.lengthComputable) {\n@@ -35,9 +35,10 @@ class UploadButtonElement extends HTMLElement {\n                 uploadButton.innerText = `${Math.round(percentComplete)}%`;\n             }\n         };\n-\n+        const me = this\n         request.onload = function () {\n             if (request.status === 200) {\n+                me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response }));\n                 uploadButton.innerHTML = '\ud83d\udce4';\n             } else {\n                 alert('Upload failed');\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 5a5cc2c..0810587 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,6 +47,11 @@\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n             getInputField().focus();\n         })\n+        document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n+            e.detail.files.forEach((file)=>{\n+                app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`)\n+            })\n+        })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nnew file mode 100644\nindex 0000000..93ad412\n--- /dev/null\n+++ b/src/snek/view/channel.py\n@@ -0,0 +1,53 @@\n+from snek.system.view import BaseView\n+import aiofiles \n+from aiohttp import web\n+import pathlib \n+\n+class ChannelAttachmentView(BaseView):\n+    \n+    async def get(self):\n+        relative_path = self.request.match_info.get(\"relative_url\")\n+        channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path)\n+        response = web.FileResponse(channel_attachment[\"path\"])\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+        )\n+        return response\n+\n+    async def post(self):\n+\n+        channel_uid = self.request.match_info.get(\"channel_uid\")\n+        user_uid = self.request.session.get(\"uid\")\n+\n+        channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False)\n+        \n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n+        reader = await self.request.multipart()\n+        attachments = []\n+\n+        while field := await reader.next():\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+\n+            attachment = await self.services.channel_attachment.create_file(\n+                channel_uid=channel_uid, name=filename,user_uid=user_uid\n+            )\n+            \n+            attachments.append(attachment)\n+            pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True)\n+            async with aiofiles.open(attachment['path'], \"wb\") as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [attachment.record for attachment in attachments],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )"}
{"repo": ".", "date": "2025-05-10", "line": "fix: Remove debug print statement in SocketService", "commit": "4d7566de9bb3f2c54954fe72d0332caecd133ffa", "diff": "commit 4d7566de9bb3f2c54954fe72d0332caecd133ffa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 20:40:56 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a3654d2..72d9b72 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -59,7 +59,6 @@ class SocketService(BaseService):\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n-                print(user_uid, flush=True)\n                 await self.send_to_user(user_uid, message)\n         except Exception as ex:\n             print(ex, flush=True)"}
{"repo": ".", "date": "2025-05-10", "line": "refactor: Removed hardcoded repository and user credentials", "commit": "2c9004418555dfc2a4c826e5c30aa0d59f332df7", "diff": "commit 2c9004418555dfc2a4c826e5c30aa0d59f332df7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 21:44:58 2025 +0200\n\n    xxx\n\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex f8bfeb7..65e18ac 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -16,11 +16,6 @@ class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n         super().__init__(client_max_size=1024*1024*1024*5)\n-        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n-        self.USERS = {\n-            'x': 'x',\n-            'bob': 'bobpass',\n-        }\n         self.add_routes([\n             web.post('/create/{repo_name}', self.create_repository),\n             web.delete('/delete/{repo_name}', self.delete_repository),\n@@ -28,7 +23,7 @@ class GitApplication(web.Application):\n             web.post('/push/{repo_name}', self.push_repository),\n             web.post('/pull/{repo_name}', self.pull_repository),\n             web.get('/status/{repo_name}', self.status_repository),\n-            web.get('/list', self.list_repositories),\n             web.get('/branches/{repo_name}', self.list_branches),\n             web.post('/branches/{repo_name}', self.create_branch),\n             web.get('/log/{repo_name}', self.commit_log),"}
{"repo": ".", "date": "2025-05-11", "line": "feat: Added SSH server functionality with user-specific home directories and password authentication.", "commit": "01846bf23f7883007b99a2e100240bf3b35b30f2", "diff": "commit 01846bf23f7883007b99a2e100240bf3b35b30f2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun May 11 07:52:22 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0a6b018..706f74e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -20,6 +20,7 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n \n+from snek.sssh import start_ssh_server\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n@@ -95,15 +96,22 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n-\n+        self.ssh_host = \"0.0.0.0\"\n+        self.ssh_port = 2042\n         self.setup_router()\n+        self.ssh_server = None \n         self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n+    async def start_ssh_server(self, app):\n+        app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)\n+        asyncio.create_task(app.ssh_server.wait_closed())\n+\n     async def prepare_asyncio(self, app):\n         app.executor = ThreadPoolExecutor(max_workers=200)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 76e6d1c..13f660f 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -32,10 +32,17 @@ class UserService(BaseService):\n             user[\"color\"] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+    def authenticate_sync(self,username,password):\n+        user = self.get_by_username_sync(username)\n+          \n+        if not user:\n+            return False\n+        if not security.verify_sync(password, user[\"password\"]):\n+            return False\n+        return True \n+\n     async def authenticate(self, username, password):\n-        print(username, password, flush=True)\n         success = await self.validate_login(username, password)\n-        print(success, flush=True)\n         if not success:\n             return None\n \n@@ -61,6 +68,20 @@ class UserService(BaseService):\n         if not path.exists():\n             return None\n         return path\n+    \n+    def get_by_username_sync(self, username):\n+        user = self.mapper.db[\"user\"].find_one(username=username, deleted_at=None)\n+        return dict(user)\n+\n+    def get_home_folder_by_username(self, username):\n+        user = self.get_by_username_sync(username)\n+        folder = pathlib.Path(f\"./drive/{user['uid']}\")\n+        if not folder.exists():\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n+        return folder\n \n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nnew file mode 100644\nindex 0000000..0106a17\n--- /dev/null\n+++ b/src/snek/sssh.py\n@@ -0,0 +1,95 @@\n+import asyncio\n+import asyncssh\n+import logging\n+import os\n+from pathlib import Path\n+import sys \n+import pty \n+\n+global _app \n+\n+\n+def set_app(app):\n+    global _app\n+    _app = app \n+\n+def get_app():\n+    return _app\n+\n+logging.basicConfig(\n+    level=logging.DEBUG,\n+    format=\"%(asctime)s - %(levelname)s - %(message)s\",\n+    handlers=[\n+        logging.FileHandler(\"sftp_server.log\"),\n+        logging.StreamHandler()\n+    ]\n+)\n+logger = logging.getLogger(__name__)\n+\n+roots = {}\n+\n+class MySFTPServer(asyncssh.SFTPServer):\n+   \n+    def __init__(self, chan: asyncssh.SSHServerChannel):\n+        self.root = get_app().services.user.get_home_folder_by_username(\n+            chan.get_extra_info('username')\n+        )\n+        self.root.mkdir(exist_ok=True)\n+        self.root = str(self.root)\n+        super().__init__(chan, chroot=self.root)\n+\n+    def map_path(self, path):\n+\n+        mapped_path = Path(self.root).joinpath(path.lstrip(b\"/\").decode())\n+        print(mapped_path)\n+        logger.debug(f\"Mapping client path {path} to {mapped_path}\")\n+        return str(mapped_path).encode()\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        \n+        logger.debug(f\"Validating credentials for user {username}\")\n+        return get_app().services.user.authenticate_sync(username,password)\n+\n+\n+async def start_ssh_server(app,host,port):\n+    set_app(app) \n+    logger.info(\"Starting SFTP server setup\")\n+    host_key_path = Path(\"drive\") / \".ssh\" / \"sftp_server_key\"\n+    host_key_path.parent.mkdir(exist_ok=True)\n+    try:\n+        if not host_key_path.exists():\n+            logger.info(f\"Generating new host key at {host_key_path}\")\n+            key = asyncssh.generate_private_key(\"ecdsa-sha2-nistp256\")\n+            key.write_private_key(host_key_path)\n+        else:\n+            logger.info(f\"Loading existing host key from {host_key_path}\")\n+            key = asyncssh.read_private_key(host_key_path)\n+    except Exception as e:\n+        logger.error(f\"Failed to generate or load host key: {e}\")\n+        raise\n+\n+    logger.info(\"Starting SFTP server on localhost:8022\")\n+    try:\n+        x = await asyncssh.listen(\n+            host=host,\n+            port=port,\n+            server_host_keys=[key],\n+            server_factory=MySSHServer,\n+            sftp_factory=MySFTPServer\n+        )\n+        return x\n+    except Exception as e:\n+        logger.error(f\"Failed to start SFTP server: {e}\")\n+        raise\n+\n+\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 43b61fe..4ead284 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -40,7 +40,7 @@ def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n     return str(uuid.uuid5(UIDNS(ns), value))\n \n \n-async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:\n     \"\"\"Hash the given data with the specified salt using SHA-256.\n \n     Args:\n@@ -63,8 +63,10 @@ async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n     obj = hashlib.sha256(salted)\n     return obj.hexdigest()\n \n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    return hash_sync(data, salt)\n \n-async def verify(string: str, hashed: str) -> bool:\n+def verify_sync(string: str, hashed: str) -> bool:\n     \"\"\"Verify if the given string matches the hashed value.\n \n     Args:\n@@ -74,4 +76,7 @@ async def verify(string: str, hashed: str) -> bool:\n     Returns:\n         bool: True if the string matches the hashed value, False otherwise.\n     \"\"\"\n-    return await hash(string) == hashed\n+    return hash_sync(string) == hashed\n+\n+async def verify(string: str, hashed: str) -> bool:\n+    return verify_sync(string, hashed)"}
{"repo": ".", "date": "2025-05-11", "line": "fix: Updated SSH port to 2242", "commit": "c48b84bf3ab7cff5dee5670e23db3d771e14fc46", "diff": "commit c48b84bf3ab7cff5dee5670e23db3d771e14fc46\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun May 11 07:52:58 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 706f74e..a8ed41c 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -97,7 +97,7 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n         self.ssh_host = \"0.0.0.0\"\n-        self.ssh_port = 2042\n+        self.ssh_port = 2242\n         self.setup_router()\n         self.ssh_server = None \n         self.executor = None"}
{"repo": ".", "date": "2025-05-12", "line": "feat: Add image conversion and resizing support in channel attachments", "commit": "f156a153de1b2f89b99cf0490eb18bf27a611fe1", "diff": "commit f156a153de1b2f89b99cf0490eb18bf27a611fe1\nAuthor: BordedDev <>\nDate:   Mon May 12 01:47:54 2025 +0200\n\n    Add image conversion and resizing support in channel attachments\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cc84391..6cb0070 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,8 +33,10 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n-    \"uvloop\",\n-    \"humanize\"\n+    'uvloop; platform_system != \"Windows\"',\n+    \"humanize\",\n+    \"Pillow\",\n+    \"pillow-heif\",\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 35e56e3..0f06499 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,5 +1,4 @@\n import click\n-import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n@@ -14,7 +13,12 @@ def cli():\n @click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n def serve(port, host, db_path):\n-    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    except ImportError:\n+        print(\"uvloop not installed, using default event loop.\")\n+\n     web.run_app(\n     )\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nindex 0106a17..848b2f9 100644\n--- a/src/snek/sssh.py\n+++ b/src/snek/sssh.py\n@@ -1,10 +1,6 @@\n-import asyncio\n import asyncssh\n import logging\n-import os\n from pathlib import Path\n-import sys \n-import pty \n \n global _app \n \ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..b708666 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -4,6 +4,9 @@ from types import SimpleNamespace\n \n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n+from mistune.plugins.formatting import strikethrough\n+from mistune.plugins.spoiler import spoiler\n+from mistune.plugins.url import url\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n@@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):\n     _allow_harmful_protocols = True\n \n     def __init__(self, app, template):\n+        super().__init__(False, True)\n+\n         self.template = template\n \n         self.app = app\n@@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):\n         markdown = Markdown(renderer=renderer)\n         return markdown(markdown_string)\n \n+\n \n def render_markdown_sync(app, markdown_string):\n     renderer = MarkdownRenderer(app, None)\n-    markdown = Markdown(renderer=renderer)\n+    markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])\n     return markdown(markdown_string)\n \n \ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d4b6819..93fd33c 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,6 +1,7 @@\n import re\n from types import SimpleNamespace\n \n+import mimetypes\n import emoji\n from bs4 import BeautifulSoup\n from jinja2 import TemplateSyntaxError, nodes\n@@ -105,21 +106,38 @@ def embed_youtube(text):\n def embed_image(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".png\",\n-            \".jpg\",\n-            \".jpeg\",\n-            \".gif\",\n-            \".webp\",\n-            \".svg\",\n-            \".bmp\",\n-            \".tiff\",\n-            \".ico\",\n-            \".heif\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+        file_mime = mimetypes.guess_type(element.attrs[\"href\"])[0]\n+\n+        if file_mime and file_mime.startswith(\"image/\") or any(\n+            ext in element.attrs[\"href\"].lower() for ext in [\n+                \".png\",\n+                \".jpg\",\n+                \".jpeg\",\n+                \".gif\",\n+                \".webp\",\n+                \".svg\",\n+                \".bmp\",\n+                \".tiff\",\n+                \".ico\",\n+                \".heif\",\n+                \".heic\",\n+            ]\n+        ):\n+            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+def enrich_image_rendering(text):\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"img\"):\n+        if element.attrs[\"src\"].startswith(\"/\"   ):\n+            picture_template = f'''\n+                                <picture>\n+                                    <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n+                                    <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n+                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                </picture>'''\n+            element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)\n \n \n@@ -205,6 +223,8 @@ class LinkifyExtension(Extension):\n         result = embed_media(result)\n         result = embed_image(result)\n         result = embed_youtube(result)\n+\n+        result = enrich_image_rendering(result)\n         return result\n \n \ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0810587..1046d2e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -49,7 +49,7 @@\n         })\n         document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n             e.detail.files.forEach((file)=>{\n-                app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`)\n+                app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)\n             })\n         })\n         textBox.addEventListener(\"paste\", async (e) => {\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 93ad412..86ed7a0 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -1,27 +1,100 @@\n+import asyncio\n+import mimetypes\n+\n+from PIL import Image\n+import pillow_heif.HeifImagePlugin\n+\n from snek.system.view import BaseView\n-import aiofiles \n+import aiofiles\n from aiohttp import web\n-import pathlib \n+import pathlib\n+\n \n class ChannelAttachmentView(BaseView):\n-    \n     async def get(self):\n         relative_path = self.request.match_info.get(\"relative_url\")\n-        channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path)\n-        response = web.FileResponse(channel_attachment[\"path\"])\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n-        response.headers[\"Content-Disposition\"] = (\n-            f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+        channel_attachment = await self.services.channel_attachment.get(\n+            relative_url=relative_path\n         )\n-        return response\n \n-    async def post(self):\n+        current_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n+\n+        format = self.request.query.get(\"format\")\n+        width = self.request.query.get(\"width\")\n+        height = self.request.query.get(\"height\")\n+\n+        if any([format, width, height]) and current_format.startswith(\"image/\"):\n+            with Image.open(channel_attachment[\"path\"]) as image:\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason=\"OK\",\n+                    headers={\n+                        \"Cache-Control\": f\"public, max-age={1337 * 420}\",\n+                        \"Content-Type\": f\"image/{format}\" if format else current_format,\n+                        \"Content-Disposition\": f'attachment; filename=\"{channel_attachment[\"name\"]}\"',\n+                    },\n+                )\n+\n+                if width or height:\n+                    width = min(int(width), image.size[0]) if width else None\n+                    height = min(int(height), image.size[1]) if height else None\n+\n+                    if width and height:\n+                        smallest_ratio = max(\n+                            image.size[0] / int(width), image.size[1] / int(height)\n+                        )\n+                        image.thumbnail(\n+                            (\n+                                int(image.size[0] / smallest_ratio),\n+                                int(image.size[1] / smallest_ratio),\n+                            )\n+                        )\n+                    elif width:\n+                        image.thumbnail(\n+                            (\n+                                int(width),\n+                                int(image.size[1] * image.size[0] / int(width)),\n+                            )\n+                        )\n+                    elif height:\n+                        image.thumbnail(\n+                            (\n+                                int(image.size[0] * image.size[1] / int(height)),\n+                                int(height),\n+                            )\n+                        )\n+\n+                await response.prepare(self.request)\n+\n+                naughty_steal = response.write\n+                loop = asyncio.get_event_loop()\n \n+                def sync_writer(*args, **kwargs):\n+                    return loop.run_until_complete(naughty_steal(*args, **kwargs))\n+\n+                setattr(response, \"write\", sync_writer)\n+\n+                image.save(response, format=self.request.query[\"format\"])\n+\n+                setattr(response, \"write\", naughty_steal)\n+                return response\n+        else:\n+            response = web.FileResponse(channel_attachment[\"path\"])\n+            response.headers[\"Cache-Control\"] = f\"public, max-age={1337 * 420}\"\n+            response.headers[\"Content-Disposition\"] = (\n+                f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+            )\n+            return response\n+\n+    async def post(self):\n         channel_uid = self.request.match_info.get(\"channel_uid\")\n         user_uid = self.request.session.get(\"uid\")\n \n-        channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False)\n-        \n+        channel_member = await self.services.channel_member.get(\n+            user_uid=user_uid, channel_uid=channel_uid, deleted_at=None, is_banned=False\n+        )\n+\n         if not channel_member:\n             return web.HTTPNotFound()\n \n@@ -29,18 +102,17 @@ class ChannelAttachmentView(BaseView):\n         attachments = []\n \n         while field := await reader.next():\n-\n             filename = field.filename\n             if not filename:\n                 continue\n \n             attachment = await self.services.channel_attachment.create_file(\n-                channel_uid=channel_uid, name=filename,user_uid=user_uid\n+                channel_uid=channel_uid, name=filename, user_uid=user_uid\n             )\n-            \n+\n             attachments.append(attachment)\n-            pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True)\n-            async with aiofiles.open(attachment['path'], \"wb\") as f:\n+            pathlib.Path(attachment[\"path\"]).parent.mkdir(parents=True, exist_ok=True)\n+            async with aiofiles.open(attachment[\"path\"], \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)"}
{"repo": ".", "date": "2025-05-13", "line": "Merge: Resolved merge conflicts and applied changes.", "commit": "ac2f68f93fd66c0d6ab3682525b2d9c94febff4d", "diff": "commit ac2f68f93fd66c0d6ab3682525b2d9c94febff4d\nMerge: c48b84b f156a15\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Tue May 13 18:19:57 2025 +0200\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add database initialization and Windows friendly solution.", "commit": "a4bea9449526fc8f6b01c02d777db5c30186b830", "diff": "commit a4bea9449526fc8f6b01c02d777db5c30186b830\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 18:32:59 2025 +0200\n\n    Windows friendly solution.\n\ndiff --git a/Makefile b/Makefile\nindex 852efd4..9b60f5a 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -20,7 +20,6 @@ serve: run\n \n run:\n \t.venv/bin/snek serve\n \t\n install: ubuntu\n \tpython3.12 -m venv .venv \ndiff --git a/pyproject.toml b/pyproject.toml\nindex 6cb0070..00a4edb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -18,7 +18,8 @@ dependencies = [\n     \"lxml\",\n     \"IPython\",\n     \"shed\",\n     \"beautifulsoup4\",\n     \"gunicorn\",\n     \"imgkit\",\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 0f06499..360bf5a 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,24 +1,45 @@\n import click\n+import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n+import sqlite3\n+import pathlib\n+import shutil \n \n @click.group()\n def cli():\n     pass\n \n+@cli.command()\n+@click.option('--db_path',default=\"snek.db\", help='Database to initialize if not exists.')\n+@click.option('--source',default=None, help='Database to initialize if not exists.')\n+def init(db_path,source):\n+    if source and pathlib.Path(source).exists():\n+        print(f\"Copying {source} to {db_path}\")\n+        shutil.copy2(source,db_path)\n+        print(\"Database initialized.\")\n+        return\n+ \n+    if pathlib.Path(db_path).exists():\n+        return\n+    print(f\"Initializing database at {db_path}\")\n+    db = sqlite3.connect(db_path)\n+    db.cursor().executescript(\n+        pathlib.Path(__file__).parent.joinpath(\"schema.sql\").read_text()\n+    )\n+    db.commit()\n+    db.close()\n+    print(\"Database initialized.\")\n+\n @cli.command()\n @click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n @click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n def serve(port, host, db_path):\n-    try:\n-        import uvloop\n-        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    except ImportError:\n-        print(\"uvloop not installed, using default event loop.\")\n-\n     web.run_app(\n     )\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a8ed41c..bbf6539 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,9 +3,9 @@ import logging\n import pathlib\n import time\n import uuid\n-\n+from snek import snode \n from snek.view.threads import ThreadsView\n-\n+import json \n logging.basicConfig(level=logging.DEBUG)\n \n from concurrent.futures import ThreadPoolExecutor\n@@ -57,7 +57,6 @@ from snek.view.web import WebView\n from snek.view.channel import ChannelAttachmentView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -99,18 +98,26 @@ class Application(BaseApplication):\n         self.ssh_host = \"0.0.0.0\"\n         self.ssh_port = 2242\n         self.setup_router()\n-        self.ssh_server = None \n+        self.ssh_server = None\n+        self.sync_service = None\n         self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.broadcast_service = None\n         self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n+        \n+\n \n+    async def snode_sync(self, app):\n+        self.sync_service = asyncio.create_task(snode.sync_service(app))\n+    \n     async def start_ssh_server(self, app):\n         app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)\n-        asyncio.create_task(app.ssh_server.wait_closed())\n+        if app.ssh_server:\n+            asyncio.create_task(app.ssh_server.wait_closed())\n \n     async def prepare_asyncio(self, app):\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex ce747c1..7a0c59a 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -16,4 +16,5 @@ class DriveItemService(BaseService):\n         if await self.save(model):\n             return model\n         errors = await model.errors\n+        print(\"XXXXXXXXXX\")\n         raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 72d9b72..eb40234 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,6 +1,7 @@\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n-\n+from datetime import datetime \n+import json \n \n class SocketService(BaseService):\n \n@@ -10,6 +11,7 @@ class SocketService(BaseService):\n             self.is_connected = True\n             self.user = user\n \n+\n         async def send_json(self, data):\n             if not self.is_connected:\n                 return False\n@@ -33,7 +35,8 @@ class SocketService(BaseService):\n         self.sockets = set()\n         self.users = {}\n         self.subscriptions = {}\n-\n+        self.last_update = str(datetime.now())\n+    \n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n@@ -54,7 +57,11 @@ class SocketService(BaseService):\n                 count += 1\n         return count\n \n+\n     async def broadcast(self, channel_uid, message):\n+        await self._broadcast(channel_uid, message)\n+\n+    async def _broadcast(self, channel_uid, message):\n         try:\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 65e18ac..3cbdcf6 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,7 +1,7 @@\n import os\n import aiohttp\n from aiohttp import web\n-import git\n+\n import shutil\n import json\n import tempfile\n@@ -14,6 +14,8 @@ logger = logging.getLogger('git_server')\n \n class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n         super().__init__(client_max_size=1024*1024*1024*5)\n         self.add_routes([\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nindex 848b2f9..6331efa 100644\n--- a/src/snek/sssh.py\n+++ b/src/snek/sssh.py\n@@ -4,7 +4,6 @@ from pathlib import Path\n \n global _app \n \n-\n def set_app(app):\n     global _app\n     _app = app \n@@ -12,19 +11,11 @@ def set_app(app):\n def get_app():\n     return _app\n \n-logging.basicConfig(\n-    level=logging.DEBUG,\n-    format=\"%(asctime)s - %(levelname)s - %(message)s\",\n-    handlers=[\n-        logging.FileHandler(\"sftp_server.log\"),\n-        logging.StreamHandler()\n-    ]\n-)\n logger = logging.getLogger(__name__)\n \n roots = {}\n \n-class MySFTPServer(asyncssh.SFTPServer):\n+class SFTPServer(asyncssh.SFTPServer):\n    \n     def __init__(self, chan: asyncssh.SSHServerChannel):\n         self.root = get_app().services.user.get_home_folder_by_username(\n@@ -35,29 +26,24 @@ class MySFTPServer(asyncssh.SFTPServer):\n         super().__init__(chan, chroot=self.root)\n \n     def map_path(self, path):\n-\n         mapped_path = Path(self.root).joinpath(path.lstrip(b\"/\").decode())\n-        print(mapped_path)\n         logger.debug(f\"Mapping client path {path} to {mapped_path}\")\n         return str(mapped_path).encode()\n \n-class MySSHServer(asyncssh.SSHServer):\n+class SSHServer(asyncssh.SSHServer):\n     def password_auth_supported(self):\n         return True\n \n     def validate_password(self, username, password):\n-        \n         logger.debug(f\"Validating credentials for user {username}\")\n-        return get_app().services.user.authenticate_sync(username,password)\n-\n+        result = get_app().services.user.authenticate_sync(username,password)\n+        logger.info(f\"Validating credentials for user {username}: {result}\")\n+        return result\n \n async def start_ssh_server(app,host,port):\n     set_app(app) \n     logger.info(\"Starting SFTP server setup\")\n+    \n     host_key_path = Path(\"drive\") / \".ssh\" / \"sftp_server_key\"\n     host_key_path.parent.mkdir(exist_ok=True)\n     try:\n@@ -72,20 +58,19 @@ async def start_ssh_server(app,host,port):\n         logger.error(f\"Failed to generate or load host key: {e}\")\n         raise\n \n-    logger.info(\"Starting SFTP server on localhost:8022\")\n+    logger.info(f\"Starting SFTP server on 127.0.0.1:{port}\")\n     try:\n         x = await asyncssh.listen(\n             host=host,\n             port=port,\n             server_host_keys=[key],\n-            server_factory=MySSHServer,\n-            sftp_factory=MySFTPServer\n+            server_factory=SSHServer,\n+            sftp_factory=SFTPServer\n         )\n         return x\n     except Exception as e:\n-        logger.error(f\"Failed to start SFTP server: {e}\")\n-        raise\n+        logger.warning(f\"Failed to start SFTP server. Already running.\")\n+        pass \n \n \ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nindex 0c19142..60fa1bb 100644\n--- a/src/snek/view/repository.py\n+++ b/src/snek/view/repository.py\n@@ -6,7 +6,7 @@ import humanize\n from aiohttp import web\n from snek.system.view import BaseView\n import asyncio \n-from git import Repo\n+"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added basic WebSocket synchronization for database changes", "commit": "ba3152f553afcfae318811a413cdea6f5be9f413", "diff": "commit ba3152f553afcfae318811a413cdea6f5be9f413\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 18:30:31 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/research/serpentarium.py b/src/snek/research/serpentarium.py\nnew file mode 100644\nindex 0000000..c87b70b\n--- /dev/null\n+++ b/src/snek/research/serpentarium.py\n@@ -0,0 +1,312 @@\n+\n+import json\n+import asyncio\n+import aiohttp\n+from aiohttp import web\n+import dataset\n+import dataset.util\n+import traceback\n+import socket\n+import base64\n+import uuid \n+\n+class DatasetMethod:\n+    def __init__(self, dt, name):\n+        self.dt = dt\n+        self.name = name \n+\n+    def __call__(self, *args, **kwargs):\n+        return self.dt.ds.call(\n+            self.dt.name,\n+            self.name,\n+            *args,\n+            **kwargs\n+        )\n+\n+\n+class DatasetTable:\n+\n+    def __init__(self, ds, name):\n+        self.ds = ds \n+        self.name = name \n+\n+    def __getattr__(self, name):\n+        return DatasetMethod(self, name)\n+\n+class WebSocketClient:\n+    def __init__(self):\n+        self.buffer = b''\n+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n+        self.connect()     \n+\n+    def connect(self):\n+        self.socket.connect((\"127.0.0.1\", 3131))\n+        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n+        handshake = (\n+            f\"GET /db HTTP/1.1\\r\\n\"\n+            f\"Host: localhost:3131\\r\\n\"\n+            f\"Upgrade: websocket\\r\\n\"\n+            f\"Connection: Upgrade\\r\\n\"\n+            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n+            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n+        )\n+        self.socket.sendall(handshake.encode('utf-8'))\n+        response = self.read_until(b'\\r\\n\\r\\n')\n+        if b'101 Switching Protocols' not in response:\n+            raise Exception(\"Failed to connect to WebSocket\")\n+\n+    def write(self, message):\n+        message_bytes = message.encode('utf-8')\n+        length = len(message_bytes)\n+        if length <= 125:\n+            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n+        elif length >= 126 and length <= 65535:\n+            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n+        else:\n+            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n+        \n+\n+    def read_until(self, delimiter): \n+        while True:\n+            find_pos = self.buffer.find(delimiter)\n+            if find_pos != -1:\n+                data = self.buffer[:find_pos+4]\n+                self.buffer = self.buffer[find_pos+4:]\n+                return data \n+            \n+            chunk = self.socket.recv(1024)\n+            if not chunk:\n+                return None\n+            self.buffer += chunk\n+            \n+    def read_exactly(self, length):\n+        while len(self.buffer) < length:\n+            chunk = self.socket.recv(length - len(self.buffer))\n+            if not chunk:\n+                return None\n+            self.buffer += chunk \n+        response = self.buffer[: length]\n+        self.buffer = self.buffer[length:]\n+        return response\n+\n+    def read(self):\n+        frame = None \n+        frame = self.read_exactly(2)\n+        length = frame[1] & 127\n+        if length == 126:\n+            length = int.from_bytes(self.read_exactly(2), 'big')\n+        elif length == 127:\n+            length = int.from_bytes(self.read_exactly(8), 'big')\n+        message = self.read_exactly(length)\n+        return message\n+    \n+    def close(self):\n+        self.socket.close()\n+\n+\n+\n+\n+class WebSocketClient2:\n+    def __init__(self, uri):\n+        self.uri = uri\n+        self.loop = asyncio.get_event_loop()\n+        self.websocket = None\n+        self.receive_queue = asyncio.Queue()\n+\n+        if self.loop.is_running():\n+            self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)\n+        else:\n+            self.loop.run_until_complete(self._connect())\n+\n+    async def _connect(self):\n+        self.websocket = await websockets.connect(self.uri)\n+        asyncio.create_task(self._receive_loop())\n+\n+    async def _receive_loop(self):\n+        try:\n+            async for message in self.websocket:\n+                await self.receive_queue.put(message)\n+        except Exception:\n+\n+    def send(self, message: str):\n+        if self.loop.is_running():\n+            asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)\n+        else:\n+            self.loop.run_until_complete(self.websocket.send(message))\n+\n+    def receive(self):\n+        future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)\n+        return future.result()\n+\n+    def close(self):\n+        if self.websocket:\n+            if self.loop.is_running():\n+                asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)\n+            else:\n+                self.loop.run_until_complete(self.websocket.close())\n+\n+\n+import websockets \n+\n+class DatasetWrapper(object):\n+\n+    def __init__(self):\n+        self.ws = WebSocketClient() \n+\n+    def begin(self):\n+        self.call(None, 'begin')\n+\n+    def commit(self):\n+        self.call(None, 'commit')\n+\n+    def __getitem__(self, name):\n+        return DatasetTable(self, name)\n+\n+    def query(self, *args, **kwargs):\n+        return self.call(None, 'query', *args, **kwargs)\n+\n+    def call(self, table, method, *args, **kwargs):\n+        payload = {\"table\": table, \"method\": method, \"args\": args, \"kwargs\": kwargs,\"call_uid\":None}\n+        payload[\"call_uid\"] = str(uuid.uuid4())    \n+        self.ws.write(json.dumps(payload))\n+        if payload[\"call_uid\"]:\n+            response = self.ws.read()\n+            return json.loads(response)['result']\n+        return True\n+\n+\n+\n+class DatasetWebSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        self.setattr(self, \"db\", self.get)\n+        self.setattr(self, \"db\", self.set)\n+        )\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                try:\n+                    data = json.loads(msg.data)\n+                    call_uid = data.get(\"call_uid\")\n+                    method = data.get(\"method\")\n+                    table_name = data.get(\"table\")\n+                    args = data.get(\"args\", {})\n+                    kwargs = data.get(\"kwargs\", {})\n+                    \n+\n+                    function = getattr(self.db, method, None)\n+                    if table_name:\n+                        function = getattr(self.db[table_name], method, None)\n+                    \n+                    print(method, table_name, args, kwargs,flush=True)\n+                    \n+                    if function:\n+                        response = {}\n+                        try:\n+                            result = function(*args, **kwargs)\n+                            print(result) \n+                            response['result'] = self.format_result(result)\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = True\n+                        except Exception as e:\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = False\n+                            response[\"error\"] = str(e)\n+                            response[\"traceback\"] = traceback.format_exc()\n+                        \n+                        if call_uid:\n+                            await self.send_str(json.dumps(response,default=str))\n+                    else:\n+                        await self.send_str(json.dumps({\"status\": \"error\", \"error\":\"Method not found.\",\"call_uid\": call_uid}))\n+                except Exception as e:\n+                    await self.send_str(json.dumps({\"success\": False,\"call_uid\": call_uid, \"error\": str(e), \"error\": str(e), \"traceback\": traceback.format_exc()},default=str))\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+\n+        return ws\n+\n+    \n+\n+    \n+\n+app = web.Application()\n+view = DatasetWebSocketView()\n+app.router.add_get('/db', view.handle)\n+\n+async def run_server():\n+\n+\n+    runner = web.AppRunner(app)\n+    await runner.setup()\n+    site = web.TCPSite(runner, 'localhost', 3131)\n+    await site.start()\n+\n+    await asyncio.Event().wait()\n+\n+async def client():\n+    print(\"x\")\n+    d = DatasetWrapper()\n+    print(\"y\")\n+    \n+    for x in range(100):\n+        for x in range(100):\n+            if d['test'].insert({\"name\": \"test\", \"number\":x}):\n+                print(\".\",end=\"\",flush=True)\n+        print(\"\")    \n+        print(d['test'].find_one(name=\"test\", order_by=\"-number\")) \n+\n+    print(\"DONE\")\n+\n+\n+\n+import time \n+async def main():\n+    await run_server()\n+\n+import sys \n+\n+if __name__ == '__main__':\n+    if sys.argv[1] == 'server':\n+        asyncio.run(main())\n+    if sys.argv[1] == 'client':\n+        asyncio.run(client())\ndiff --git a/src/snek/research/serptest.py b/src/snek/research/serptest.py\nnew file mode 100644\nindex 0000000..94e22be\n--- /dev/null\n+++ b/src/snek/research/serptest.py\n@@ -0,0 +1,51 @@\n+import snek.serpentarium\n+\n+import time \n+\n+from concurrent.futures import ProcessPoolExecutor\n+\n+durations = []\n+\n+def task1():\n+    global durations \n+    client = snek.serpentarium.DatasetWrapper()\n+\n+    start=time.time()\n+    for x in range(1500):\n+    \n+        client['a'].delete()\n+        client['a'].insert({\"foo\": x})\n+        client['a'].find(foo=x) \n+        client['a'].find_one(foo=x)\n+        client['a'].count()\n+    client.close()\n+    duration1 = f\"{time.time()-start}\"\n+    durations.append(duration1)\n+    print(durations)\n+\n+with ProcessPoolExecutor(max_workers=4) as executor:\n+    tasks = [executor.submit(task1),\n+        executor.submit(task1),\n+        executor.submit(task1),\n+        executor.submit(task1)\n+    ]\n+    for task in tasks:\n+        task.result()\n+\n+\n+import dataset \n+start=time.time()\n+for x in range(1500):\n+\n+    client['a'].delete()\n+    client['a'].insert({\"foo\": x})\n+    print([dict(row) for row in client['a'].find(foo=x)])\n+    print(dict(client['a'].find_one(foo=x) ))\n+    print(client['a'].count())\n+duration2 = f\"{time.time()-start}\"\n+\n+print(duration1,duration2)\ndiff --git a/src/snek/schema.sql b/src/snek/schema.sql\nnew file mode 100644\nindex 0000000..5b9c9a5\n--- /dev/null\n+++ b/src/snek/schema.sql\n@@ -0,0 +1,103 @@\n+CREATE TABLE IF NOT EXISTS http_access (\n+\tid INTEGER NOT NULL, \n+\tcreated TEXT, \n+\tpath TEXT, \n+\tduration FLOAT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE TABLE IF NOT EXISTS user (\n+\tid INTEGER NOT NULL, \n+\tcolor TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\temail TEXT, \n+\tis_admin TEXT, \n+\tlast_ping TEXT, \n+\tnick TEXT, \n+\tpassword TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tusername TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);\n+CREATE TABLE IF NOT EXISTS channel (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tcreated_by_uid TEXT, \n+\tdeleted_at TEXT, \n+\tdescription TEXT, \n+\t\"index\" BIGINT, \n+\tis_listed BOOLEAN, \n+\tis_private BOOLEAN, \n+\tlabel TEXT, \n+\tlast_message_on TEXT, \n+\ttag TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);\n+CREATE TABLE IF NOT EXISTS channel_member (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tis_banned BOOLEAN, \n+\tis_moderator BOOLEAN, \n+\tis_muted BOOLEAN, \n+\tis_read_only BOOLEAN, \n+\tlabel TEXT, \n+\tnew_count BIGINT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);\n+CREATE TABLE IF NOT EXISTS broadcast (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tmessage TEXT, \n+\tcreated_at TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE TABLE IF NOT EXISTS channel_message (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\thtml TEXT, \n+\tmessage TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);\n+CREATE TABLE IF NOT EXISTS notification (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tmessage TEXT, \n+\tobject_type TEXT, \n+\tobject_uid TEXT, \n+\tread_at TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);\n+CREATE TABLE IF NOT EXISTS repository (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tis_private BIGINT, \n+\tname TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);\ndiff --git a/src/snek/snode.py b/src/snek/snode.py\nnew file mode 100644\nindex 0000000..ae42a1f\n--- /dev/null\n+++ b/src/snek/snode.py\n@@ -0,0 +1,116 @@\n+import aiohttp\n+\n+ENABLED = False\n+\n+import aiohttp\n+import asyncio\n+from aiohttp import web\n+\n+import sqlite3\n+\n+import dataset\n+from sqlalchemy import event\n+from sqlalchemy.engine import Engine\n+\n+import json \n+\n+queue = asyncio.Queue()\n+\n+class State:\n+    do_not_sync = False \n+\n+async def sync_service(app):\n+    if not ENABLED:\n+        return  \n+    session = aiohttp.ClientSession()\n+        async def receive():\n+\n+            queries_synced = 0\n+            async for msg in ws:\n+                if msg.type == aiohttp.WSMsgType.TEXT:\n+                    try:\n+                        data = json.loads(msg.data)\n+                        State.do_not_sync = True \n+                        app.db.execute(*data)\n+                        app.db.commit()\n+                        State.do_not_sync = False\n+                        queries_synced += 1\n+                        print(\"queries synced: \" + str(queries_synced))\n+                        print(*data)\n+                        await app.services.socket.broadcast_event()\n+                    except Exception as e:\n+                        print(e)\n+                    pass \n+                elif msg.type == aiohttp.WSMsgType.ERROR:\n+                    break\n+        async def write():\n+            while True:\n+                msg = await queue.get()\n+                await ws.send_str(json.dumps(msg,default=str))\n+                queue.task_done()\n+\n+        await asyncio.gather(receive(), write())\n+\n+    await session.close()\n+\n+queries_queued = 0\n+@event.listens_for(Engine, \"before_cursor_execute\")\n+def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):\n+    if not ENABLED:\n+        return\n+    global queries_queued\n+    if State.do_not_sync:\n+        print(statement,parameters)\n+        return\n+    if statement.startswith(\"SELECT\"):\n+        return\n+    queue.put_nowait((statement, parameters))\n+    queries_queued += 1\n+    print(\"Queries queued: \" + str(queries_queued))\n+\n+async def websocket_handler(request):\n+    queries_broadcasted = 0 \n+    ws = web.WebSocketResponse()\n+    await ws.prepare(request)\n+    request.app['websockets'].append(ws)\n+    async for msg in ws:\n+        if msg.type == aiohttp.WSMsgType.TEXT:\n+            for client in request.app['websockets']:\n+                if client != ws:\n+                    await client.send_str(msg.data)\n+            cursor = request.app['db'].cursor()\n+            data = json.loads(msg.data)\n+            queries_broadcasted += 1\n+\n+            cursor.execute(*data)\n+            cursor.close()\n+            print(\"Queries broadcasted: \" + str(queries_broadcasted))\n+        elif msg.type == aiohttp.WSMsgType.ERROR:\n+            print(f'WebSocket connection closed with exception {ws.exception()}')\n+\n+    request.app['websockets'].remove(ws)\n+    return ws\n+\n+app = web.Application()\n+app['websockets'] = []\n+\n+app.router.add_get('/ws', websocket_handler)\n+\n+async def on_startup(app):\n+    app['db'] = sqlite3.connect('snek.db')\n+    print(\"Server starting...\")\n+\n+async def on_cleanup(app):\n+    for ws in app['websockets']:\n+        await ws.close()\n+    app['db'].close()\n+\n+app.on_startup.append(on_startup)\n+app.on_cleanup.append(on_cleanup)\n+\n+\n+if __name__ == '__main__':\n+    web.run_app(app, host='127.0.0.1', port=3131)"}
{"repo": ".", "date": "2025-05-13", "line": "refactor: Moved WebSocketClient to system module", "commit": "adad5ed4fe37038442d247d9246795a82d31093c", "diff": "commit adad5ed4fe37038442d247d9246795a82d31093c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 19:08:18 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/research/serpentarium.py b/src/snek/research/serpentarium.py\nindex c87b70b..e425a23 100644\n--- a/src/snek/research/serpentarium.py\n+++ b/src/snek/research/serpentarium.py\n@@ -33,76 +33,6 @@ class DatasetTable:\n     def __getattr__(self, name):\n         return DatasetMethod(self, name)\n \n-class WebSocketClient:\n-    def __init__(self):\n-        self.buffer = b''\n-        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n-        self.connect()     \n-\n-    def connect(self):\n-        self.socket.connect((\"127.0.0.1\", 3131))\n-        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n-        handshake = (\n-            f\"GET /db HTTP/1.1\\r\\n\"\n-            f\"Host: localhost:3131\\r\\n\"\n-            f\"Upgrade: websocket\\r\\n\"\n-            f\"Connection: Upgrade\\r\\n\"\n-            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n-            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n-        )\n-        self.socket.sendall(handshake.encode('utf-8'))\n-        response = self.read_until(b'\\r\\n\\r\\n')\n-        if b'101 Switching Protocols' not in response:\n-            raise Exception(\"Failed to connect to WebSocket\")\n-\n-    def write(self, message):\n-        message_bytes = message.encode('utf-8')\n-        length = len(message_bytes)\n-        if length <= 125:\n-            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n-        elif length >= 126 and length <= 65535:\n-            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n-        else:\n-            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n-        \n-\n-    def read_until(self, delimiter): \n-        while True:\n-            find_pos = self.buffer.find(delimiter)\n-            if find_pos != -1:\n-                data = self.buffer[:find_pos+4]\n-                self.buffer = self.buffer[find_pos+4:]\n-                return data \n-            \n-            chunk = self.socket.recv(1024)\n-            if not chunk:\n-                return None\n-            self.buffer += chunk\n-            \n-    def read_exactly(self, length):\n-        while len(self.buffer) < length:\n-            chunk = self.socket.recv(length - len(self.buffer))\n-            if not chunk:\n-                return None\n-            self.buffer += chunk \n-        response = self.buffer[: length]\n-        self.buffer = self.buffer[length:]\n-        return response\n-\n-    def read(self):\n-        frame = None \n-        frame = self.read_exactly(2)\n-        length = frame[1] & 127\n-        if length == 126:\n-            length = int.from_bytes(self.read_exactly(2), 'big')\n-        elif length == 127:\n-            length = int.from_bytes(self.read_exactly(8), 'big')\n-        message = self.read_exactly(length)\n-        return message\n-    \n-    def close(self):\n-        self.socket.close()\n-\n \n \n \ndiff --git a/src/snek/system/websocket.py b/src/snek/system/websocket.py\nnew file mode 100644\nindex 0000000..c54b094\n--- /dev/null\n+++ b/src/snek/system/websocket.py\n@@ -0,0 +1,81 @@\n+class WebSocketClient:\n+    def __init__(self, hostname, port):\n+        self.buffer = b''\n+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n+        self.hostname = hostname \n+        self.port = port\n+        self.connect()     \n+    \n+    def __getattr__(self, method, *args, **kwargs):\n+        if method in self.__dict__.keys():\n+            return self.__dict__[method]\n+        def call(*args, **kwargs):\n+            self.write(json.dumps({'method': method, 'args': args, 'kwargs': kwargs}))\n+            return json.loads(self.read())\n+        return call\n+\n+    def connect(self):\n+        self.socket.connect((self.hostname, self.port))\n+        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n+        handshake = (\n+            f\"GET /db HTTP/1.1\\r\\n\"\n+            f\"Host: localhost:3131\\r\\n\"\n+            f\"Upgrade: websocket\\r\\n\"\n+            f\"Connection: Upgrade\\r\\n\"\n+            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n+            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n+        )\n+        self.socket.sendall(handshake.encode('utf-8'))\n+        response = self.read_until(b'\\r\\n\\r\\n')\n+        if b'101 Switching Protocols' not in response:\n+            raise Exception(\"Failed to connect to WebSocket\")\n+\n+    def write(self, message):\n+        message_bytes = message.encode('utf-8')\n+        length = len(message_bytes)\n+        if length <= 125:\n+            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n+        elif length >= 126 and length <= 65535:\n+            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n+        else:\n+            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n+        \n+\n+    def read_until(self, delimiter): \n+        while True:\n+            find_pos = self.buffer.find(delimiter)\n+            if find_pos != -1:\n+                data = self.buffer[:find_pos+4]\n+                self.buffer = self.buffer[find_pos+4:]\n+                return data \n+            \n+            chunk = self.socket.recv(1024)\n+            if not chunk:\n+                return None\n+            self.buffer += chunk\n+            \n+    def read_exactly(self, length):\n+        while len(self.buffer) < length:\n+            chunk = self.socket.recv(length - len(self.buffer))\n+            if not chunk:\n+                return None\n+            self.buffer += chunk \n+        response = self.buffer[: length]\n+        self.buffer = self.buffer[length:]\n+        return response\n+\n+    def read(self):\n+        frame = None \n+        frame = self.read_exactly(2)\n+        length = frame[1] & 127\n+        if length == 126:\n+            length = int.from_bytes(self.read_exactly(2), 'big')\n+        elif length == 127:\n+            length = int.from_bytes(self.read_exactly(8), 'big')\n+        message = self.read_exactly(length)\n+        return message\n+    \n+    def close(self):\n+        self.socket.close()\n+\n+"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Remove timing output from task execution", "commit": "2e324ff11815d3c67fffa8e8d5f3e3554f154b57", "diff": "commit 2e324ff11815d3c67fffa8e8d5f3e3554f154b57\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 19:13:50 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex bbf6539..fa211d1 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -132,10 +132,7 @@ class Application(BaseApplication):\n             task = await self.tasks.get()\n             self.db.begin()\n             try:\n-                task_start = time.time()\n                 await task\n-                task_end = time.time()\n-                print(f\"Task {task} took {task_end - task_start} seconds\")\n                 self.tasks.task_done()\n             except Exception as ex:\n                 print(ex)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Implement image click overlay for full-size viewing", "commit": "d09055986e9a5d971f58075a5e939a268deb26be", "diff": "commit d09055986e9a5d971f58075a5e939a268deb26be\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:18:47 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1046d2e..a862d21 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -314,6 +314,36 @@\n         }\n     });\n \n+    messagesContainer.addEventListener('click', (e) => {\n+       if(e.target.tagName != 'IMG')\n+           return\n+      const img = e.target\n+      const overlay = document.createElement('div');\n+      overlay.style.position = 'fixed';\n+      overlay.style.top = 0;\n+      overlay.style.left = 0;\n+      overlay.style.width = '100%';\n+      overlay.style.height = '100%';\n+      overlay.style.backgroundColor = 'rgba(0,0,0,0.9)';\n+      overlay.style.display = 'flex';\n+      overlay.style.justifyContent = 'center';\n+      overlay.style.alignItems = 'center';\n+      overlay.style.zIndex = 9999;\n+\n+      const fullImg = document.createElement('img');\n+      fullImg.src = img.src;\n+      fullImg.alt = img.alt;\n+      fullImg.style.maxWidth = '90%';\n+      fullImg.style.maxHeight = '90%';\n+\n+      overlay.appendChild(fullImg);\n+\n+      document.body.appendChild(overlay);\n+\n+      overlay.addEventListener('click', () => {\n+        document.body.removeChild(overlay);\n+      });\n+    });\n     initInputField(getInputField());\n     updateLayout(true);\n </script>"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image embeds", "commit": "8cd2f16c5c46318cb035b882197b516ae5532452", "diff": "commit 8cd2f16c5c46318cb035b882197b516ae5532452\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:20:43 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 93fd33c..27dec46 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -123,7 +123,7 @@ def embed_image(text):\n                 \".heic\",\n             ]\n         ):\n-            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}?width=420\" alt=\"{element.attrs[\"href\"]}\" />'\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added width attribute to image source", "commit": "015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda", "diff": "commit 015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:24:29 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 27dec46..d69f568 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]?width=420}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected image width attribute in template rendering", "commit": "12d287042415554c581e7d4fcfc81bd3d733fa02", "diff": "commit 12d287042415554c581e7d4fcfc81bd3d733fa02\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:25:00 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d69f568..40b5f2d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]?width=420}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]}?width=420\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image source", "commit": "964a747f42ade75dcfced5395f46727f0508172c", "diff": "commit 964a747f42ade75dcfced5395f46727f0508172c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:27:26 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 40b5f2d..d018ee8 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,11 +131,12 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n+            element.attrs[\"src\"].append(\"?width=420\")\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]}?width=420\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image sources", "commit": "319c1b1b5264933a7ea1d7af6541be2a410a3328", "diff": "commit 319c1b1b5264933a7ea1d7af6541be2a410a3328\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:28:31 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d018ee8..7e9b316 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"].append(\"?width=420\")\n+            element.attrs[\"src\"] += \"?width=420\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected webp image type in template", "commit": "a21e3590ef4ddad292fb914cb3454d07eb622413", "diff": "commit a21e3590ef4ddad292fb914cb3454d07eb622413\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:30:48 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7e9b316..bad98fc 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n-                                    <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n+                                    <source srcset=\"{element.attrs[\"src\"]}\" type=\"image/webp\" />\n                                     <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Reduce image width and fix image URL in web template", "commit": "b55d74fb124b90ee24d158bc94c401b0ff19edb9", "diff": "commit b55d74fb124b90ee24d158bc94c401b0ff19edb9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:35:42 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex bad98fc..7026e6d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"] += \"?width=420\"\n+            element.attrs[\"src\"] += \"?width=240\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex a862d21..689efd3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -331,7 +331,11 @@\n       overlay.style.zIndex = 9999;\n \n       const fullImg = document.createElement('img');\n-      fullImg.src = img.src;\n+\n+      const urlObj = new URL(img.src);\n+      urlObj.search = ''\n+        fullImg.src = urlObj.toString();\n+\n       fullImg.alt = img.alt;\n       fullImg.style.maxWidth = '90%';\n       fullImg.style.maxHeight = '90%';"}
{"repo": ".", "date": "2025-05-13", "line": "style: Added cursor pointer to chat message images", "commit": "3858dcbd62e4032a02e9d25dffd000ade4dc7bbe", "diff": "commit 3858dcbd62e4032a02e9d25dffd000ade4dc7bbe\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 21:25:49 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 90f20b4..54bc61a 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -151,7 +151,9 @@ footer {\n   }\n \n }\n-\n+.chat-messages > picture > img {    \n+    cursor: pointer;\n+}\n .chat-messages::-webkit-scrollbar {\n   display: none;\n }"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add height parameter to image rendering", "commit": "0ea0cd96dbe536b09cb3549de47766a756f04008", "diff": "commit 0ea0cd96dbe536b09cb3549de47766a756f04008\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 22:54:21 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7026e6d..8bd1ca3 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"] += \"?width=240\"\n+            element.attrs[\"src\"] += \"?width=240&height=240\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Encode attachment URLs for safe transmission", "commit": "af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9", "diff": "commit af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 23:33:24 2025 +0200\n\n    Push\n\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 86ed7a0..9d36d8f 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -8,7 +8,7 @@ from snek.system.view import BaseView\n import aiofiles\n from aiohttp import web\n import pathlib\n-\n+import urllib.parse \n \n class ChannelAttachmentView(BaseView):\n     async def get(self):\n@@ -116,10 +116,16 @@ class ChannelAttachmentView(BaseView):\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n+        attachment_records = []\n+        for attachment in attachments:\n+            attachment_record = attachment.record\n+            attachment_record['relative_url'] = urllib.parse.quote(attachment_record['relative_url'])\n+            attachment_records.append(attachment_record)\n+\n         return web.json_response(\n             {\n                 \"message\": \"Files uploaded successfully\",\n-                \"files\": [attachment.record for attachment in attachments],\n+                \"files\": attachment_records,\n                 \"channel_uid\": channel_uid,\n             }\n         )"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added online user list and typing indicator", "commit": "db6d6c0106267f56822ae378a4c88385d025051a", "diff": "commit db6d6c0106267f56822ae378a4c88385d025051a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 13:18:53 2025 +0200\n\n    Update live type.\n\ndiff --git a/src/snek/balancer.py b/src/snek/balancer.py\nnew file mode 100644\nindex 0000000..09d0b5a\n--- /dev/null\n+++ b/src/snek/balancer.py\n@@ -0,0 +1,123 @@\n+import asyncio\n+import sys\n+\n+class LoadBalancer:\n+    def __init__(self, backend_ports):\n+        self.backend_ports = backend_ports\n+        self.backend_processes = []\n+        self.client_counts = [0] * len(backend_ports)\n+        self.lock = asyncio.Lock()\n+\n+    async def start_backend_servers(self,port,workers):\n+        for x in range(workers):\n+            port += 1\n+            process = await asyncio.create_subprocess_exec(\n+                sys.executable,\n+                sys.argv[0],\n+                'backend',\n+                str(port),\n+                stdout=asyncio.subprocess.PIPE,\n+                stderr=asyncio.subprocess.PIPE\n+            )\n+            port += 1\n+            self.backend_processes.append(process)\n+            print(f\"Started backend server on port {(port-1)/port} with PID {process.pid}\")\n+\n+    async def handle_client(self, reader, writer):\n+        async with self.lock:\n+            min_clients = min(self.client_counts)\n+            server_index = self.client_counts.index(min_clients)\n+            self.client_counts[server_index] += 1\n+        backend = ('127.0.0.1', self.backend_ports[server_index])\n+        try:\n+            backend_reader, backend_writer = await asyncio.open_connection(*backend)\n+\n+            async def forward(r, w):\n+                try:\n+                    while True:\n+                        data = await r.read(1024)\n+                        if not data:\n+                            break\n+                        w.write(data)\n+                        await w.drain()\n+                except asyncio.CancelledError:\n+                    pass\n+                finally:\n+                    w.close()\n+\n+            task1 = asyncio.create_task(forward(reader, backend_writer))\n+            task2 = asyncio.create_task(forward(backend_reader, writer))\n+            await asyncio.gather(task1, task2)\n+        except Exception as e:\n+            print(f\"Error: {e}\")\n+        finally:\n+            writer.close()\n+            async with self.lock:\n+                self.client_counts[server_index] -= 1\n+\n+    async def monitor(self):\n+        while True:\n+            await asyncio.sleep(5)\n+            print(\"Connected clients per server:\")\n+            for i, count in enumerate(self.client_counts):\n+                print(f\"Server {self.backend_ports[i]}: {count} clients\")\n+\n+    async def start(self, host='0.0.0.0', port=8081,workers=5):\n+        await self.start_backend_servers(port,workers)\n+        server = await asyncio.start_server(self.handle_client, host, port)\n+        monitor_task = asyncio.create_task(self.monitor())\n+\n+        try:\n+            async with server:\n+                await server.serve_forever()\n+        except asyncio.CancelledError:\n+            pass\n+        finally:\n+            for process in self.backend_processes:\n+                process.terminate()\n+            await asyncio.gather(*(p.wait() for p in self.backend_processes))\n+            print(\"Backend processes terminated.\")\n+\n+async def backend_echo_server(port):\n+    async def handle_echo(reader, writer):\n+        try:\n+            while True:\n+                data = await reader.read(1024)\n+                if not data:\n+                    break\n+                writer.write(data)\n+                await writer.drain()\n+        except Exception:\n+            pass\n+        finally:\n+            writer.close()\n+\n+    server = await asyncio.start_server(handle_echo, '127.0.0.1', port)\n+    print(f\"Backend echo server running on port {port}\")\n+    await server.serve_forever()\n+\n+async def main():\n+    backend_ports = [8001, 8003, 8005, 8006]\n+    lb = LoadBalancer(backend_ports)\n+    await lb.start()\n+\n+if __name__ == \"__main__\":\n+    if len(sys.argv) > 1:\n+        if sys.argv[1] == 'backend':\n+            port = int(sys.argv[2])\n+            from snek.app import Application\n+            snek = Application(port=port)\n+            web.run_app(snek, port=port, host='127.0.0.1')\n+        elif sys.argv[1] == 'sync':\n+            from snek.sync import app\n+            web.run_app(snek, port=port, host='127.0.0.1')\n+    else:\n+        try:\n+            asyncio.run(main())\n+        except KeyboardInterrupt:\n+            print(\"Shutting down...\")\n+\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex f8a000f..d9ba45d 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -30,7 +30,7 @@ class ChannelMessageService(BaseService):\n         except Exception as ex:\n             print(ex, flush=True)\n \n-        if await self.save(model):\n+        if await super().save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n \n@@ -50,6 +50,12 @@ class ChannelMessageService(BaseService):\n             \"username\": user[\"username\"],\n         }\n \n+    async def save(self, model):\n+        context = model.record  \n+        template = self.app.jinja2_env.get_template(\"message.html\")\n+        model[\"html\"] = template.render(**context)\n+        return await super().save(model)\n+\n     async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n         results = []\n         offset = page * page_size\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 388d5c0..7fbe787 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -36,4 +36,4 @@ class ChatService(BaseService):\n             self.services.notification.create_channel_message(channel_message_uid)\n         )\n \n-        return True\n+        return channel_message\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 91d80d3..210c2e8 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -5,8 +5,64 @@\n \n+ import {app} from '../app.js'\n+ class MessageList extends HTMLElement {\n+      constructor() {\n+        super();\n+        app.ws.addEventListener(\"update_message_text\",(data)=>{\n+            this.updateMessageText(data.data.message_uid,data.data.text)\n+        })\n+          app.ws.addEventListener(\"set_typing\",(data)=>{\n+\t\t    this.triggerGlow(data.data.user_uid)\t\n+\n+\t    })\n+\n+          this.items = [];\n+      }\n+      updateMessageText(uid,text){\n+        const messageDiv = this.querySelector(\"div[data-uid=\\\"\"+uid+\"\\\"]\")\n+          if(!messageDiv){\n+            return\n+          }\n+          const textElement = messageDiv.querySelector(\".text\")\n+          textElement.innerText = text \n+          textElement.style.display = text == '' ? 'none' : 'block'\n+        \n+      }\n+        triggerGlow(uid) {\n+\t \tlet lastElement = null;\n+            this.querySelectorAll(\".avatar\").forEach((el)=>{\n+\t\t   const div = el.closest('a');\n+\t\t   if(el.href.indexOf(uid)!=-1){\n+\t\t\tlastElement = el\n+            }    \t\t    \n+\n+\t      })\n+        if(lastElement){\n+            lastElement.classList.add(\"glow\")\n+            setTimeout(()=>{\n+                lastElement.classList.remove(\"glow\")\n+            },1000)\n+        }\n+ \t\n+    \t}\n+\n+      set data(items) {\n+        this.items = items;\n+          this.render();\n+      }\n+      render() {\n+        this.innerHTML = '';\n+\n+        \n+      }\n+\n+    }\n+\n+    customElements.define('message-list', MessageList);\n \n-class MessageListElement extends HTMLElement {\n+class MessageListElementOLD extends HTMLElement {\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n@@ -167,4 +223,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\ndiff --git a/src/snek/static/online-users.js b/src/snek/static/online-users.js\nnew file mode 100644\nindex 0000000..8b13789\n--- /dev/null\n+++ b/src/snek/static/online-users.js\n@@ -0,0 +1 @@\n+\ndiff --git a/src/snek/static/user-list.css b/src/snek/static/user-list.css\nnew file mode 100644\nindex 0000000..d831c2f\n--- /dev/null\n+++ b/src/snek/static/user-list.css\n@@ -0,0 +1,28 @@\n+   .user-list__item {\n+      display: flex;\n+      margin-bottom: 1em;\n+      padding: 10px;\n+      border-radius: 8px;\n+    }\n+    .user-list__item-avatar {\n+      margin-right: 10px;\n+      border-radius: 50%;\n+      overflow: hidden;\n+      width: 40px;\n+      height: 40px;\n+      display: block;\n+    }\n+    .user-list__item-content {\n+      flex: 1;\n+    }\n+    .user-list__item-name {\n+      font-weight: bold;\n+    }\n+    .user-list__item-text {\n+      margin: 5px 0;\n+    }\n+    .user-list__item-time {\n+      font-size: 0.8em;\n+      color: gray;\n+    }\ndiff --git a/src/snek/static/user-list.js b/src/snek/static/user-list.js\nnew file mode 100644\nindex 0000000..5aaba50\n--- /dev/null\n+++ b/src/snek/static/user-list.js\n@@ -0,0 +1,59 @@\n+ class UserList extends HTMLElement {\n+      constructor() {\n+        super();\n+        this.users = [];\n+      }\n+\n+      set data(userArray) {\n+        this.users = userArray;\n+          this.render();\n+      }\n+\n+      formatRelativeTime(timestamp) {\n+        const now = new Date();\n+        const msgTime = new Date(timestamp);\n+        const diffMs = now - msgTime;\n+        const minutes = Math.floor(diffMs / 60000);\n+        const hours = Math.floor(minutes / 60);\n+        const days = Math.floor(hours / 24);\n+\n+        if (days > 0) {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;\n+        } else if (hours > 0) {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;\n+        } else {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;\n+        }\n+      }\n+\n+      render() {\n+        this.innerHTML = '';\n+\n+        this.users.forEach(user => {\n+          const html = `\n+            <div class=\"user-list__item\"\n+              data-uid=\"${user.uid}\"\n+              data-color=\"${user.color}\"\n+              data-user_nick=\"${user.nick}\"\n+              data-created_at=\"${user.created_at}\"\n+              data-user_uid=\"${user.user_uid}\">\n+              \n+              <a class=\"user-list__item-avatar\" style=\"background-color: ${user.color}; color: black;\" href=\"/user/${user.uid}.html\">\n+                <img width=\"40px\" height=\"40px\" src=\"/avatar/${user.uid}.svg\" alt=\"${user.nick}\">\n+              </a>\n+              \n+              <div class=\"user-list__item-content\">\n+                <div class=\"user-list__item-name\" style=\"color: ${user.color};\">${user.nick}</div>\n+                <div class=\"user-list__item-time\" data-created_at=\"${user.last_ping}\">\n+                  <a href=\"/user/${user.uid}.html\">profile</a>\n+                  <a href=\"/channel/${user.uid}.html\">dm</a>\n+                </div>\n+              </div>\n+            </div>\n+          `;\n+          this.insertAdjacentHTML(\"beforeend\", html);\n+        });\n+      }\n+    }\n+\n+    customElements.define('user-list', UserList);\ndiff --git a/src/snek/sync.py b/src/snek/sync.py\nnew file mode 100644\nindex 0000000..fb2a9af\n--- /dev/null\n+++ b/src/snek/sync.py\n@@ -0,0 +1,135 @@\n+\n+\n+\n+\n+class DatasetWebSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        self.setattr(self, \"db\", self.get)\n+        self.setattr(self, \"db\", self.set)\n+        )\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                try:\n+                    data = json.loads(msg.data)\n+                    call_uid = data.get(\"call_uid\")\n+                    method = data.get(\"method\")\n+                    table_name = data.get(\"table\")\n+                    args = data.get(\"args\", {})\n+                    kwargs = data.get(\"kwargs\", {})\n+                    \n+\n+                    function = getattr(self.db, method, None)\n+                    if table_name:\n+                        function = getattr(self.db[table_name], method, None)\n+                    \n+                    print(method, table_name, args, kwargs,flush=True)\n+                    \n+                    if function:\n+                        response = {}\n+                        try:\n+                            result = function(*args, **kwargs)\n+                            print(result) \n+                            response['result'] = self.format_result(result)\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = True\n+                        except Exception as e:\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = False\n+                            response[\"error\"] = str(e)\n+                            response[\"traceback\"] = traceback.format_exc()\n+                        \n+                        if call_uid:\n+                            await self.send_str(json.dumps(response,default=str))\n+                    else:\n+                        await self.send_str(json.dumps({\"status\": \"error\", \"error\":\"Method not found.\",\"call_uid\": call_uid}))\n+                except Exception as e:\n+                    await self.send_str(json.dumps({\"success\": False,\"call_uid\": call_uid, \"error\": str(e), \"error\": str(e), \"traceback\": traceback.format_exc()},default=str))\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+\n+        return ws\n+\n+ class BroadCastSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+        app = request.app\n+        app['broadcast_clients'].append(ws)\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                print(msg.data)\n+                for client in app['broadcast_clients'] if not client == ws:\n+                    await client.send_str(msg.data)\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+        app['broadcast_clients'].remove(ws)\n+        return ws\n+    \n+\n+app = web.Application()\n+view = DatasetWebSocketView()\n+app['broadcast_clients'] = []\n+app.router.add_get('/db', view.handle)\n+app.router.add_get('/broadcast', sync_view.handle)\n+\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex a373c2d..7baa67a 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -16,6 +16,10 @@\n   <script src=\"/html-frame.js\" type=\"module\"></script>\n   <script src=\"/app.js\" type=\"module\"></script>\n   <script src=\"/file-manager.js\" type=\"module\"></script>\n+  <script src=\"/user-list.js\"></script>\n+  <script src=\"/message-list.js\" type=\"module\"></script>\n+  <link rel=\"stylesheet\" href=\"/user-list.css\">\n+\n   <link rel=\"stylesheet\" href=\"/base.css\">\n <link\n     rel=\"stylesheet\"\ndiff --git a/src/snek/templates/online.html b/src/snek/templates/online.html\nnew file mode 100644\nindex 0000000..a241662\n--- /dev/null\n+++ b/src/snek/templates/online.html\n@@ -0,0 +1,117 @@\n+<style>\n+  position: fixed;\n+  top: 50%;\n+  left: 50%;\n+  transform: translate(-50%, -50%);\n+\n+  border: none;\n+  border-radius: 12px;\n+  padding: 24px;\n+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n+  width: 90%;\n+  max-width: 400px;\n+\n+  animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out;\n+  z-index: 1000;\n+}\n+\n+  background: rgba(0, 0, 0, 0.7);\n+  backdrop-filter: blur(4px);\n+}\n+\n+  font-size: 1.5rem;\n+  font-weight: bold;\n+  margin-bottom: 16px;\n+}\n+\n+  font-size: 1rem;\n+  margin-bottom: 20px;\n+}\n+\n+  display: flex;\n+  justify-content: flex-end;\n+  gap: 10px;\n+}\n+\n+  padding: 8px 16px;\n+  font-size: 0.95rem;\n+  border-radius: 8px;\n+  border: none;\n+  cursor: pointer;\n+  transition: background 0.2s ease;\n+}\n+\n+  color: white;\n+}\n+\n+}\n+\n+}\n+\n+}\n+\n+@keyframes fadeIn {\n+  from { opacity: 0; }\n+  to { opacity: 1; }\n+}\n+\n+@keyframes scaleIn {\n+  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n+  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n+}\n+</style>\n+\n+\n+<dialog id=\"online-users\">\n+    <div class=\"dialog-backdrop\">\n+      <div class=\"dialog-box\">\n+        <div class=\"dialog-title\"><h2>Currently online</h2></div>\n+        <div class=\"dialog-content\"><user-list></user-list></div>\n+        <div class=\"dialog-actions\">\n+          <button class=\"dialog-button primary\">Close</button>\n+        </div>\n+      </div>\n+    </div>\n+  </dialog>\n+\n+<script>\n+const onlineDialog = document.getElementById(\"online-users\");\n+const dialogButton = onlineDialog.querySelector('.dialog-button.primary');\n+\n+dialogButton.addEventListener('click', () => {\n+    onlineDialog.close();\n+});\n+\n+async function showOnlineUsers() {\n+    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n+    onlineDialog.querySelector('user-list').data = users;\n+    onlineDialog.showModal();\n+}\n+</script>\n+\n+\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 689efd3..38f723c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -9,19 +9,19 @@\n \n \n <section class=\"chat-area\">\n-    <div class=\"chat-messages\">\n-        {% for message in messages %}\n-            {% autoescape false %}\n-            {{ message.html }}\n-            {% endautoescape %}\n-        {% endfor %}\n-    </div>\n+    <message-list class=\"chat-messages\">\n+    {% for message in messages %}\n+        {% autoescape false %}\n+        {{ message.html }}\n+        {% endautoescape %}\n+    {% endfor %}\n+    </message-list>\n     <div class=\"chat-input\">\n-        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <textarea list=\"chat-input-autocomplete-items\" placeholder=\"Type a message...\" rows=\"2\" autocomplete=\"on\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n-\n+{% include \"online.html\" %}\n <script type=\"module\">\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n@@ -30,18 +30,95 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-    \n+    getInputField().autoComplete = {\n+        \"/online\": () =>{\n+            showOnlineUsers();\n+        },\n+        \"/clear\": () => {\n+            document.querySelector(\".chat-messages\").innerHTML = '';\n+        },\n+        \"/live\": () =>{\n+            getInputField().liveType = !getInputField().liveType\n+        }\n+    }\n+\n+\n     function initInputField(textBox) {\n-        textBox.addEventListener('keydown', (e) => {\n+        if(textBox.liveType == undefined){\n+            textBox.liveType = false\n+        }\n+        textBox.addEventListener('keydown',async (e) => {\n+            if(e.key === \"ArrowUp\"){\n+                const value = findDivAboveText(e.target.value).querySelector('.text')\n+                e.target.value = value.textContent\n+                console.info(\"HIERR\")\n+                return\n+            }\n+            if (e.key === \"Tab\") {\n+\n+                const message = e.target.value.trim();\n+                if (!message) {\n+                    return\n+                }\n+                let autoCompleteHandler = null;\n+                Object.keys(e.target.autoComplete).forEach((key)=>{\n+                    if(key.startsWith(message)){\n+                        if(autoCompleteHandler){\n+                            return \n+                        }\n+                        autoCompleteHandler = key\n+                    }\n+                })\n+                if(autoCompleteHandler){\n+                    e.preventDefault();\n+                    e.target.value = autoCompleteHandler;\n+                    return\n+                }\n+            }\n             if (e.key === 'Enter' && !e.shiftKey) {\n                 e.preventDefault();\n                 const message = e.target.value.trim();\n-                if (message) {\n-                    app.rpc.sendMessage(channelUid, message);\n+                if (!message) {\n+                    return\n+                }\n+                let autoCompleteHandler = e.target.autoComplete[message]\n+                if(autoCompleteHandler){\n+                    const value = message;\n                     e.target.value = '';\n+                    autoCompleteHandler(value)\n+                    return\n+                }\n+\n+                e.target.value = '';\n+                if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){\n+                        \n+                \n+                    app.rpc.updateMessageText(textBox.lastMessageUid, message)\n+                textBox.lastMessageUid = null\n+                    return \n                 }\n+\n+                const messageResponse = await app.rpc.sendMessage(channelUid, message);\n+                \n \t    }else{\n-\t\tapp.rpc.set_typing(channelUid)\n+\t\tif(textBox.liveType){\n+            \n+            if(e.target.value[0] == \"/\"){\n+                return\n+            }\n+            if(!textBox.lastMessageUid){\n+                textBox.lastMessageUid = '?'\n+                app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{\n+                    textBox.lastMessageUid = messageResponse\n+                })\n+            }\n+            if(textBox.lastMessageUid == '?'){\n+                return;\n+            }\n+            app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n+        }\n+        app.rpc.set_typing(channelUid)\n+        \n \t    }\n         });\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n@@ -81,29 +158,7 @@\n             }\n         });\n \n-\t function triggerGlow(uid) {\n-\t \tdocument.querySelectorAll(\".avatar\").forEach((el)=>{\n-\t\t   const div = el.closest('a');\n-\t\t   if(el.href.indexOf(uid)!=-1){\n-\t\t\tel.classList.add('glow')\n-\t\t    \tlet originalColor = el.style.backgroundColor \n-\t\t\tsetTimeout(()=>{\n-\t\t\t\tel.classList.remove('glow')\n-\t\t\t},1200)\n-\t\t    }\n-\n-\t      })\n- \t\n-    \t}\n-\tapp.ws.addEventListener(\"set_typing\",(data)=>{\n-\t\ttriggerGlow(data.data.user_uid)\t\n-\n-\t})\n-\t\t\n+\t\t\t\n \n         const chatInput = document.querySelector(\".chat-area\")\n         chatInput.addEventListener(\"drop\", async (e) => {\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 645ccbc..0469e53 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -16,6 +16,9 @@ from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n \n+import logging \n+\n+logger = logging.getLogger(__name__)\n \n class RPCView(BaseView):\n \n@@ -170,11 +173,34 @@ class RPCView(BaseView):\n                 )\n             return channels\n \n-        async def send_message(self, channel_uid, message):\n+        async def update_message_text(self,message_uid, text):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid, channel_uid, message)\n+            message = await self.services.channel_message.get(message_uid)    \n+            if message[\"user_uid\"] != self.user_uid:\n+                raise Exception(\"Not allowed\")\n+            await self.services.socket.broadcast(message[\"channel_uid\"], {\n+                \"channel_uid\": message[\"channel_uid\"],\n+                \"event\": \"update_message_text\",\n+                \"data\": {\n+                     \n+                    \"message_uid\": message_uid,\n+                    \"text\": text\n+                }\n+            })\n+            message[\"message\"] = text\n+            if not text:\n+                message['deleted_at'] = now()\n+            else:\n+                message['deleted_at'] = None\n+            await self.services.channel_message.save(message)\n             return True\n \n+        async def send_message(self, channel_uid, message):\n+            self._require_login()\n+            message = await self.services.chat.send(self.user_uid, channel_uid, message)\n+            \n+            return message[\"uid\"]\n+\n         async def echo(self, *args):\n             self._require_login()\n             return args\n@@ -243,12 +269,14 @@ class RPCView(BaseView):\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False\n+                    logger.exception(ex)\n                 if result != \"noresponse\":\n                     await self._send_json(\n                         {\"callId\": call_id, \"success\": success, \"data\": result}\n                     )\n             except Exception as ex:\n                 print(str(ex), flush=True)\n+                logger.exception(ex)\n                 await self._send_json(\n                     {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n                 )\n@@ -259,15 +287,15 @@ class RPCView(BaseView):\n         async def get_online_users(self, channel_uid):\n             self._require_login()\n \n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_online_users(channel_uid)\n+            results = [\n+                    record.record async for record  in self.services.channel.get_online_users(channel_uid)\n             ]\n+            for result in results:\n+                del result['email']\n+                del result['password']\n+                del result['deleted_at']\n+                del result['updated_at']\n+            return results\n \n         async def echo(self, obj):\n             await self.ws.send_json(obj)\n@@ -314,6 +342,7 @@ class RPCView(BaseView):\n                         await rpc(msg.json())\n                 except Exception as ex:\n                     print(\"Deleting socket\", ex, flush=True)\n+                    logger.exception(ex)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Update message text and add seconds since last update check\n\nThis commit introduces a check to prevent updating messages that are too old, enhancing message integrity and preventing potential issues with outdated data. It also updates the message text and broadcasts the update to the relevant channel. The UI has been updated to display the html of the message.", "commit": "25d109beedf030523ac5d357dbbb1f9efb919edb", "diff": "commit 25d109beedf030523ac5d357dbbb1f9efb919edb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 19:32:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 524a8a4..7fd1a27 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,6 +1,6 @@\n from snek.model.user import UserModel\n from snek.system.model import BaseModel, ModelField\n-\n+from datetime import datetime,timezone \n \n class ChannelMessageModel(BaseModel):\n     channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n@@ -8,6 +8,9 @@ class ChannelMessageModel(BaseModel):\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n \n+    def get_seconds_since_last_update(self):\n+        return int((datetime.now(timezone.utc) - datetime.fromisoformat(self[\"updated_at\"])).total_seconds())\n+\n     async def get_user(self) -> UserModel:\n         return await self.app.services.user.get(uid=self[\"user_uid\"])\n \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 54bc61a..479d36b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -151,7 +151,7 @@ footer {\n   }\n \n }\n-.chat-messages > picture > img {    \n+.chat-messages picture img {    \n     cursor: pointer;\n }\n .chat-messages::-webkit-scrollbar {\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 210c2e8..6415a34 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -10,7 +10,7 @@\n       constructor() {\n         super();\n         app.ws.addEventListener(\"update_message_text\",(data)=>{\n-            this.updateMessageText(data.data.message_uid,data.data.text)\n+            this.updateMessageText(data.data.uid,data.data)\n         })\n           app.ws.addEventListener(\"set_typing\",(data)=>{\n \t\t    this.triggerGlow(data.data.user_uid)\t\n@@ -19,14 +19,18 @@\n \n           this.items = [];\n       }\n-      updateMessageText(uid,text){\n+      updateMessageText(uid,message){\n         const messageDiv = this.querySelector(\"div[data-uid=\\\"\"+uid+\"\\\"]\")\n+\n           if(!messageDiv){\n             return\n           }\n+          const receivedHtml = document.createElement(\"div\")\n+          receivedHtml.innerHTML = message.html\n+          const html = receivedHtml.querySelector(\".text\").innerHTML\n           const textElement = messageDiv.querySelector(\".text\")\n-          textElement.innerText = text \n-          textElement.style.display = text == '' ? 'none' : 'block'\n+          textElement.innerHTML = html \n+          textElement.style.display = message.text == '' ? 'none' : 'block'\n         \n       }\n         triggerGlow(uid) {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 38f723c..113b77d 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,7 +47,18 @@\n         if(textBox.liveType == undefined){\n             textBox.liveType = false\n         }\n+        let typeTimeout = null;\n         textBox.addEventListener('keydown',async (e) => {\n+            if(typeTimeout){\n+                clearTimeout(typeTimeout)\n+                typeTimeout = null\n+            }\n+            if(e.target.liveType){\n+                typeTimeout = setTimeout(()=>{\n+                    e.target.lastMessageUid = null\n+                    e.target.value = ''\n+                },3000)\n+            }\n             if(e.key === \"ArrowUp\"){\n                 const value = findDivAboveText(e.target.value).querySelector('.text')\n                 e.target.value = value.textContent\n@@ -102,7 +113,9 @@\n                 \n \t    }else{\n \t\tif(textBox.liveType){\n-            \n+            if(e.target.value.endsWith(\"\\n\") || e.target.value.endsWith(\" \")){\n+                return\n+            } \n             if(e.target.value[0] == \"/\"){\n                 return\n             }\n@@ -116,8 +129,10 @@\n                 return;\n             }\n             app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n+        }else{\n+            app.rpc.set_typing(channelUid)\n         }\n-        app.rpc.set_typing(channelUid)\n+\n         \n \t    }\n         });\n@@ -294,6 +309,10 @@\n     }\n \n     app.addEventListener(\"channel-message\", (data) => {\n+        let display = 'block';\n+        if(!data.text || !data.text.trim()){\n+            display = \"none\";\n+        }\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n                 channelSidebar.notify(data);\n@@ -316,6 +335,7 @@\n \n         const message = document.createElement(\"div\");\n         message.innerHTML = data.html;\n+        message.style.display = display\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);\n         setTimeout(() => {\n@@ -373,6 +393,9 @@\n        if(e.target.tagName != 'IMG')\n            return\n       const img = e.target\n+      if(e.target.classList.contains('avatar')){\n+          return\n+      }\n       const overlay = document.createElement('div');\n       overlay.style.position = 'fixed';\n       overlay.style.top = 0;\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 0469e53..de94633 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -178,22 +178,28 @@ class RPCView(BaseView):\n             message = await self.services.channel_message.get(message_uid)    \n             if message[\"user_uid\"] != self.user_uid:\n                 raise Exception(\"Not allowed\")\n-            await self.services.socket.broadcast(message[\"channel_uid\"], {\n-                \"channel_uid\": message[\"channel_uid\"],\n-                \"event\": \"update_message_text\",\n-                \"data\": {\n-                     \n-                    \"message_uid\": message_uid,\n-                    \"text\": text\n-                }\n-            })\n-            message[\"message\"] = text\n+            \n+            if message.get_seconds_since_last_update() > 3:\n+                return {\"error\": \"Message too old\",\"seconds_since_last_update\": message.get_seconds_since_last_update(),\"success\": False}\n+\n+            message['message'] = text  \n             if not text:\n                 message['deleted_at'] = now()\n             else:\n                 message['deleted_at'] = None\n+\n             await self.services.channel_message.save(message)\n-            return True\n+            data = message.record \n+            data['text'] = message[\"message\"]\n+            data['message_uid'] = message_uid\n+\n+            await self.services.socket.broadcast(message[\"channel_uid\"], {\n+                \"channel_uid\": message[\"channel_uid\"],\n+                \"event\": \"update_message_text\",\n+                \"data\": message.record\n+            })\n+            \n+            return {\"success\": True}\n \n         async def send_message(self, channel_uid, message):\n             self._require_login()"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Implemented help and online user dialogs with styling and functionality", "commit": "dd80f3732b7f500acdd92f6e44f42f9ade0f205b", "diff": "commit dd80f3732b7f500acdd92f6e44f42f9ade0f205b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 23:16:28 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 479d36b..ffe8c1d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -412,8 +412,8 @@ a {\n             display: block;\n             width: 100%;\n         }\n-        \n-    }\n+    }    \n+    \n     body {\n         justify-content: flex-start;\n@@ -429,3 +429,89 @@ a {\n         position:sticky;\n }\n+\n+dialog {\n+  position: fixed;\n+  top: 50%;\n+  left: 50%;\n+  transform: translate(-50%, -50%);\n+\n+  border: none;\n+  border-radius: 12px;\n+  padding: 24px;\n+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n+  width: 90%;\n+  max-width: 400px;\n+\n+  animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;\n+  z-index: 1000;\n+}\n+\n+dialog::backdrop {\n+  background: rgba(0, 0, 0, 0.7);\n+  backdrop-filter: blur(4px);\n+}\n+\n+dialog .dialog-title {\n+  font-size: 1.5rem;\n+  font-weight: bold;\n+  margin-bottom: 16px;\n+}\n+\n+dialog .dialog-content {\n+  font-size: 1rem;\n+  margin-bottom: 20px;\n+}\n+\n+dialog .dialog-actions {\n+  display: flex;\n+  justify-content: flex-end;\n+  gap: 10px;\n+}\n+\n+dialog .dialog-button {\n+  padding: 8px 16px;\n+  font-size: 0.95rem;\n+  border-radius: 8px;\n+  border: none;\n+  cursor: pointer;\n+  transition: background 0.2s ease;\n+}\n+\n+\n+@keyframes dialogFadeIn {\n+  from { opacity: 0; }\n+  to { opacity: 1; }\n+}\n+\n+@keyframes dialogScaleIn {\n+  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n+  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n+}\n+\n+dialog .dialog-button.primary {\n+  color: white;\n+}\n+\n+dialog .dialog-button.primary:hover {\n+}\n+\n+dialog .dialog-button.secondary {\n+}\n+\n+dialog .dialog-button.secondary:hover {\n+}\n+\n+\ndiff --git a/src/snek/templates/dialog_help.html b/src/snek/templates/dialog_help.html\nnew file mode 100644\nindex 0000000..dae5f81\n--- /dev/null\n+++ b/src/snek/templates/dialog_help.html\n@@ -0,0 +1,61 @@\n+\n+<dialog id=\"help-dialog\">\n+  <div class=\"dialog-backdrop\">\n+    <div class=\"dialog-box\">\n+      <div class=\"dialog-title\"><h2>Help</h2></div>\n+      <div class=\"dialog-content\">\n+        <help-command-list></help-command-list>\n+      </div>\n+      <div class=\"dialog-actions\">\n+        <button class=\"dialog-button primary\">Close</button>\n+      </div>\n+    </div>\n+  </div>\n+</dialog>\n+\n+<script>\n+  class HelpCommandListComponent extends HTMLElement {\n+    helpCommands = [\n+      {\n+        command: \"/help\",\n+        description: \"Show this help message\"\n+      },\n+      {\n+        command: \"/online\",\n+        description: \"Show online users\"\n+      },\n+      {\n+        command: \"/clear\",\n+        description: \"Clear the board\"\n+      },\n+      {\n+        command: \"/img-gen\",\n+        description: \"Generate an image\"\n+      }\n+    ];\n+\n+    constructor() {\n+      super();\n+    }\n+\n+    connectedCallback() {\n+      this.innerHTML = this.helpCommands\n+            .map(cmd => `<div><h2>${cmd.command}</h2><div>${cmd.description}</div></div>`)\n+        .join('');\n+    }\n+  }\n+\n+  customElements.define('help-command-list', HelpCommandListComponent);\n+\n+  const helpDialog = document.getElementById(\"help-dialog\");\n+  const helpCloseButton = helpDialog.querySelector('.dialog-button.primary');\n+    function showHelp() {\n+        helpDialog.showModal();\n+\n+        helpCloseButton.focus();\n+    }\n+    helpCloseButton.addEventListener('click', () => {\n+      helpDialog.close();\n+    });\n+\n+</script>\ndiff --git a/src/snek/templates/dialog_online.html b/src/snek/templates/dialog_online.html\nnew file mode 100644\nindex 0000000..3fc1c08\n--- /dev/null\n+++ b/src/snek/templates/dialog_online.html\n@@ -0,0 +1,28 @@\n+\n+<dialog id=\"online-users\">\n+  <div class=\"dialog-backdrop\">\n+    <div class=\"dialog-box\">\n+      <div class=\"dialog-title\"><h2>Online Users</h2></div>\n+      <div class=\"dialog-content\"><user-list></user-list></div>\n+      <div class=\"dialog-actions\">\n+        <button class=\"dialog-button primary\">Close</button>\n+      </div>\n+    </div>\n+  </div>\n+</dialog>\n+\n+<script>\n+const onlineUsersDialog = document.getElementById(\"online-users\");\n+const closeButton = onlineUsersDialog.querySelector('.dialog-button.primary');\n+\n+closeButton.addEventListener('click', () => {\n+    onlineUsersDialog.close();\n+});\n+\n+async function showOnline() {\n+    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n+    onlineUsersDialog.querySelector('user-list').data = users;\n+    onlineUsersDialog.showModal();\n+    closeButton.focus();\n+}\n+</script>\ndiff --git a/src/snek/templates/online.html b/src/snek/templates/online.html\ndeleted file mode 100644\nindex a241662..0000000\n--- a/src/snek/templates/online.html\n+++ /dev/null\n@@ -1,117 +0,0 @@\n-<style>\n-  position: fixed;\n-  top: 50%;\n-  left: 50%;\n-  transform: translate(-50%, -50%);\n-\n-  border: none;\n-  border-radius: 12px;\n-  padding: 24px;\n-  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n-  width: 90%;\n-  max-width: 400px;\n-\n-  animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out;\n-  z-index: 1000;\n-}\n-\n-  background: rgba(0, 0, 0, 0.7);\n-  backdrop-filter: blur(4px);\n-}\n-\n-  font-size: 1.5rem;\n-  font-weight: bold;\n-  margin-bottom: 16px;\n-}\n-\n-  font-size: 1rem;\n-  margin-bottom: 20px;\n-}\n-\n-  display: flex;\n-  justify-content: flex-end;\n-  gap: 10px;\n-}\n-\n-  padding: 8px 16px;\n-  font-size: 0.95rem;\n-  border-radius: 8px;\n-  border: none;\n-  cursor: pointer;\n-  transition: background 0.2s ease;\n-}\n-\n-  color: white;\n-}\n-\n-}\n-\n-}\n-\n-}\n-\n-@keyframes fadeIn {\n-  from { opacity: 0; }\n-  to { opacity: 1; }\n-}\n-\n-@keyframes scaleIn {\n-  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n-  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n-}\n-</style>\n-\n-\n-<dialog id=\"online-users\">\n-    <div class=\"dialog-backdrop\">\n-      <div class=\"dialog-box\">\n-        <div class=\"dialog-title\"><h2>Currently online</h2></div>\n-        <div class=\"dialog-content\"><user-list></user-list></div>\n-        <div class=\"dialog-actions\">\n-          <button class=\"dialog-button primary\">Close</button>\n-        </div>\n-      </div>\n-    </div>\n-  </dialog>\n-\n-<script>\n-const onlineDialog = document.getElementById(\"online-users\");\n-const dialogButton = onlineDialog.querySelector('.dialog-button.primary');\n-\n-dialogButton.addEventListener('click', () => {\n-    onlineDialog.close();\n-});\n-\n-async function showOnlineUsers() {\n-    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n-    onlineDialog.querySelector('user-list').data = users;\n-    onlineDialog.showModal();\n-}\n-</script>\n-\n-\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 113b77d..db91188 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -21,7 +21,8 @@\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n-{% include \"online.html\" %}\n+{% include \"dialog_help.html\" %}\n+{% include \"dialog_online.html\" %}\n <script type=\"module\">\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n@@ -32,13 +33,16 @@\n     }\n     getInputField().autoComplete = {\n         \"/online\": () =>{\n-            showOnlineUsers();\n+            showOnline();\n         },\n         \"/clear\": () => {\n             document.querySelector(\".chat-messages\").innerHTML = '';\n         },\n         \"/live\": () =>{\n             getInputField().liveType = !getInputField().liveType\n+        },\n+        \"/help\": () => {\n+            showHelp();\n         }\n     }"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added /live command to help dialog", "commit": "79c39828f0a3282f53e3322e19b211b5559466a1", "diff": "commit 79c39828f0a3282f53e3322e19b211b5559466a1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 23:30:23 2025 +0200\n\n    update.\n\ndiff --git a/src/snek/templates/dialog_help.html b/src/snek/templates/dialog_help.html\nindex dae5f81..eb72662 100644\n--- a/src/snek/templates/dialog_help.html\n+++ b/src/snek/templates/dialog_help.html\n@@ -31,7 +31,11 @@\n       {\n         command: \"/img-gen\",\n         description: \"Generate an image\"\n-      }\n+      },\n+     {\n+        command: \"/live\",\n+         description: \"Toggle live typing mode\"\n+     }\n     ];\n \n     constructor() {"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Implemented user availability service and updated startup sequence", "commit": "c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa", "diff": "commit c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:04:19 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex fa211d1..24af03b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -105,12 +105,15 @@ class Application(BaseApplication):\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n         self.broadcast_service = None\n-        self.on_startup.append(self.start_ssh_server)\n+        self.user_availability_service_task = None\n+        \n         self.on_startup.append(self.prepare_asyncio)\n+        self.on_startup.append(self.start_user_availability_service)\n+        self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_database)\n         \n-\n-\n+    async def start_user_availability_service(self, app):\n+        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())\n     async def snode_sync(self, app):\n         self.sync_service = asyncio.create_task(snode.sync_service(app))\n     \ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex eb40234..ecc7bf9 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,7 +1,11 @@\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n from datetime import datetime \n-import json \n+import json\n+import asyncio\n+import logging \n+logger = logging.getLogger(__name__)\n+from snek.system.model import now\n \n class SocketService(BaseService):\n \n@@ -36,10 +40,28 @@ class SocketService(BaseService):\n         self.users = {}\n         self.subscriptions = {}\n         self.last_update = str(datetime.now())\n-    \n+        \n+\n+    async def user_availability_service(self):\n+        logger.info(\"User availability update service started.\")\n+        while True:\n+            logger.info(\"Updating user availability...\")\n+            users_updated = []\n+            for s in self.sockets:\n+                if not s.is_connected:\n+                    continue\n+                if not s.user in users_updated:\n+                    s.user[\"last_ping\"] = now()\n+                    await self.app.services.user.save(s.user)\n+                    users_updated.append(s.user)\n+            logger.info(f\"Updated user availability for {len(users_updated)} online users.\")\n+            await asyncio.sleep(60)\n+\n+\n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n+        logger.info(f\"Added socket for user {s.user['username']}\")\n         if not self.users.get(user_uid):\n             self.users[user_uid] = set()\n         self.users[user_uid].add(s)\n@@ -62,16 +84,21 @@ class SocketService(BaseService):\n         await self._broadcast(channel_uid, message)\n \n     async def _broadcast(self, channel_uid, message):\n+        sent = 0\n         try:\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n                 await self.send_to_user(user_uid, message)\n+                sent += 1\n         except Exception as ex:\n             print(ex, flush=True)\n+        logger.info(f\"Broadcasted a message to {sent} users.\")\n         return True\n \n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n+            logger.info(f\"Removed socket for user {s.user['username']}\")\n             self.sockets.remove(s)\n+"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Improve file uploads and user activity tracking\n\nThis commit enhances file upload handling in the web interface by concatenating multiple file names into a single message for broadcasting. It also adds user last ping tracking to the socket service. Finally, it fixes an issue with image format handling in the channel attachment view.", "commit": "93462d4c4b93c4f7eb81702801df9159cbb64e8e", "diff": "commit 93462d4c4b93c4f7eb81702801df9159cbb64e8e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:32:54 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex ecc7bf9..d0ff024 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -61,6 +61,8 @@ class SocketService(BaseService):\n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n+        s.user[\"last_ping\"] = now()\n+        await self.app.services.user.save(s.user)\n         logger.info(f\"Added socket for user {s.user['username']}\")\n         if not self.users.get(user_uid):\n             self.users[user_uid] = set()\n@@ -89,8 +91,7 @@ class SocketService(BaseService):\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n-                await self.send_to_user(user_uid, message)\n-                sent += 1\n+                sent += await self.send_to_user(user_uid, message)\n         except Exception as ex:\n             print(ex, flush=True)\n         logger.info(f\"Broadcasted a message to {sent} users.\")\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex db91188..02e60df 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -144,9 +144,11 @@\n             getInputField().focus();\n         })\n         document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n+            let message = \"\"\n             e.detail.files.forEach((file)=>{\n-                app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)\n+                message += `[${file.name}](/channel/attachment/${file.relative_url})`\n             })\n+            app.rpc.sendMessage(channelUid,message)\n         })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 9d36d8f..7580642 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -17,20 +17,21 @@ class ChannelAttachmentView(BaseView):\n             relative_url=relative_path\n         )\n \n-        current_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n-\n-        format = self.request.query.get(\"format\")\n+        original_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n+        format_ = self.request.query.get(\"format\")\n         width = self.request.query.get(\"width\")\n         height = self.request.query.get(\"height\")\n \n-        if any([format, width, height]) and current_format.startswith(\"image/\"):\n+        if any([format_, width, height]) and original_format.startswith(\"image/\"):\n+            if not format_:\n+                format_ = original_format.split(\"/\")[1]\n             with Image.open(channel_attachment[\"path\"]) as image:\n                 response = web.StreamResponse(\n                     status=200,\n                     reason=\"OK\",\n                     headers={\n                         \"Cache-Control\": f\"public, max-age={1337 * 420}\",\n-                        \"Content-Type\": f\"image/{format}\" if format else current_format,\n+                        \"Content-Type\": f\"image/{format_}\",\n                         \"Content-Disposition\": f'attachment; filename=\"{channel_attachment[\"name\"]}\"',\n                     },\n                 )\n@@ -65,19 +66,21 @@ class ChannelAttachmentView(BaseView):\n                         )\n \n                 await response.prepare(self.request)\n-\n                 naughty_steal = response.write\n-                loop = asyncio.get_event_loop()\n-\n+                tasks = []\n                 def sync_writer(*args, **kwargs):\n-                    return loop.run_until_complete(naughty_steal(*args, **kwargs))\n-\n+                    tasks.append(naughty_steal(*args, **kwargs))\n+                    return True\n+                \n                 setattr(response, \"write\", sync_writer)\n \n-                image.save(response, format=self.request.query[\"format\"])\n+                image.save(response, format=format_)\n \n                 setattr(response, \"write\", naughty_steal)\n+                \n+                await asyncio.gather(*tasks)\n+                \n                 return response\n         else:\n             response = web.FileResponse(channel_attachment[\"path\"])"}
{"repo": ".", "date": "2025-05-16", "line": "fix: Increased last ping cooldown time", "commit": "c387225a6e8aa826b944ff3c53c4045db25db758", "diff": "commit c387225a6e8aa826b944ff3c53c4045db25db758\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:41:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex f2288cb..ecc9519 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -86,7 +86,7 @@ class ChannelService(BaseService):\n             if (\n                 datetime.fromisoformat(now())\n                 - datetime.fromisoformat(user[\"last_ping\"])\n-            ).total_seconds() < 20:\n+            ).total_seconds() < 180:\n                 yield user\n \n     async def get_for_user(self, user_uid):"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Added cache enable/disable functionality", "commit": "00557ec9eaab7256d5c129fc8c00c12650ea3fd3", "diff": "commit 00557ec9eaab7256d5c129fc8c00c12650ea3fd3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 01:38:42 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex eed888a..8f8cdc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -14,10 +14,13 @@ class Cache:\n         self.cache = {}\n         self.max_items = max_items\n         self.stats = {}\n+        self.enabled = False\n         self.lru = []\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n+        if not self.enabled:\n+            return None\n         await self.update_stat(args, \"get\")\n         try:\n             self.lru.pop(self.lru.index(args))\n@@ -76,6 +79,8 @@ class Cache:\n         )\n \n     async def set(self, args, result):\n+        if not self.enabled:\n+            return\n         is_new = args not in self.cache\n         self.cache[args] = result\n         await self.update_stat(args, \"set\")\n@@ -94,6 +99,8 @@ class Cache:\n \n     async def delete(self, args):\n+        if not self.enabled:\n+            return\n         await self.update_stat(args, \"delete\")\n         if args in self.cache:\n             try:"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor index.html with improved styling and content", "commit": "c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7", "diff": "commit c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 00:53:27 2025 +0200\n\n    t:\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex a1e8894..e12fe2b 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -1,31 +1,288 @@\n-<!DOCTYPE html>\n+\n+                        <!DOCTYPE html>\n+                        <html lang=\"en\">\n+                        <head>\n+                            <meta charset=\"UTF-8\">\n+                            <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+\t\t\t\t\t\t\t<style>\n+\t\t\t\t\t\t\t\tbody {\n+\t\t\t\t\t\t\t\t}\n+\n+\t\t\t\t\t\t\t\t\n+    * { margin:0; padding:0; box-sizing:border-box; }\n+    body {\n+      font-family: 'Segoe UI',sans-serif;\n+      line-height:1.5;\n+    }\n+    a:hover { text-decoration: underline; }\n+\n+    .container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }\n+\n+    .hero {\n+      text-align: center;\n+      padding: 4rem 0;\n+    }\n+    .hero h1 {\n+      font-size: 3rem;\n+      -webkit-background-clip: text;\n+      color: transparent;\n+    }\n+    .hero p {\n+      font-size: 1.2rem;\n+      margin: 1rem 0 2rem;\n+    }\n+    .btn {\n+      display: inline-block;\n+      padding: .75rem 1.5rem;\n+      margin: .5rem;\n+      font-weight: bold;\n+      border-radius: 4px;\n+      transition: background .2s;\n+    }\n+\n+    .grid {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n+      gap: 1.5rem;\n+      margin-top: 2rem;\n+    }\n+    .card {\n+      border-radius: 6px;\n+      padding: 1.5rem;\n+      box-shadow: 0 2px 6px rgba(0,0,0,0.6);\n+    }\n+    .card h3 {\n+      margin-bottom: .75rem;\n+    }\n+    .card ul {\n+      list-style: disc inside;\n+      margin-top: .5rem;\n+    }\n+\n+    footer {\n+      text-align: center;\n+      font-size: .9rem;\n+      padding: 2rem 0;\n+    }\n+    footer code {\n+      padding: 2px 4px;\n+      border-radius: 3px;\n+    }\n+\n+    @media (max-width: 480px) {\n+      .hero h1 { font-size: 2.4rem; }\n+      .btn { width: 100%; box-sizing: border-box; text-align:center; }\n+    }\n+  \n+\n+\t\t\t\t\t\t\t</style>\n+                        </head>\n+                        <body>\n+                            <!DOCTYPE html>\n <html lang=\"en\">\n <head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Snek chat by Molodetz</title>\n-    <link rel=\"stylesheet\" href=\"generic-form.css\">\n-    <link rel=\"stylesheet\" href=\"base.css\">\n-<style>\n-  .registration-container {\n-        max-width: 300px;\n-        margin: 20px auto;\n-        padding: 20px;\n-  }\n-</style>\n-    <script src=\"/fancy-button.js\"></script>\n+  <meta charset=\"UTF-8\" />\n+  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n+  <title>Snek \u2013 The Ultimate Web Community</title>\n+  <style>\n+    * { margin:0; padding:0; box-sizing:border-box; }\n+    body {\n+      font-family: 'Segoe UI',sans-serif;\n+      line-height:1.5;\n+    }\n+    a:hover { text-decoration: underline; }\n+\n+    .container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }\n+\n+    .hero {\n+      text-align: center;\n+      padding: 4rem 0;\n+    }\n+    .hero h1 {\n+      font-size: 3rem;\n+      -webkit-background-clip: text;\n+      color: transparent;\n+    }\n+    .hero p {\n+      font-size: 1.2rem;\n+      margin: 1rem 0 2rem;\n+    }\n+    .btn {\n+      display: inline-block;\n+      padding: .75rem 1.5rem;\n+      margin: .5rem;\n+      font-weight: bold;\n+      border-radius: 4px;\n+      transition: background .2s;\n+    }\n+\n+    .grid {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n+      gap: 1.5rem;\n+      margin-top: 2rem;\n+    }\n+    .card {\n+      border-radius: 6px;\n+      padding: 1.5rem;\n+      box-shadow: 0 2px 6px rgba(0,0,0,0.6);\n+    }\n+    .card h3 {\n+      margin-bottom: .75rem;\n+    }\n+    .card ul {\n+      list-style: disc inside;\n+      margin-top: .5rem;\n+    }\n+\n+    footer {\n+      text-align: center;\n+      font-size: .9rem;\n+      padding: 2rem 0;\n+    }\n+    footer code {\n+      padding: 2px 4px;\n+      border-radius: 3px;\n+    }\n+\n+    @media (max-width: 480px) {\n+      .hero h1 { font-size: 2.4rem; }\n+      .btn { width: 100%; box-sizing: border-box; text-align:center; }\n+    }\n+  </style>\n </head>\n <body>\n-  <div class=\"registration-container\">\n+\n+  <header class=\"container hero\">\n     <h1>Snek</h1>\n-    <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n-    So Snek came through, lean and optimized.</p>\n-    <div style=\"text-align: center;\">\n-    <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n-    <span style=\"padding:10px;\">OR</span>\n-    <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n-    </div>\n-  </div>\n+    <p>The Ultimate Web Community for Devs, Testers &amp; AI Enthusiasts</p>\n+    <a href=\"/login.html\" class=\"btn\">Login</a>\n+    <a href=\"/register.html\" class=\"btn\">Register</a>\n+  </header>\n+\n+  <main class=\"container\">\n+\n+    <section id=\"features\" class=\"grid\">\n+      <div class=\"card\">\n+        <h3>File Sharing</h3>\n+        <ul>\n+          <li>SFTP storage with your Snek credentials</li>\n+          <li>WebDAV support \u2013 same login</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Git Repositories</h3>\n+        <ul>\n+          <li>Configure repos for any official Git client</li>\n+          <li>Instant setup, push &amp; pull</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>AI Powerhouse</h3>\n+        <ul>\n+          <li>Chat with free &amp; commercial AIs</li>\n+          <li>Generate AI-powered images</li>\n+          <li>Build your own AI bots in &lt;5 minutes (copy/paste example)</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Dev &amp; Terminal</h3>\n+        <ul>\n+          <li>Ubuntu web terminal in-browser</li>\n+          <li>Full profile &amp; permissions management</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Chat &amp; Media</h3>\n+        <ul>\n+          <li>Upload any file type in chat</li>\n+          <li>Rich media support (audio, video, images\u2026)</li>\n+          <li>Direct messaging with other users</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Privacy &amp; Community</h3>\n+        <ul>\n+          <li>No logging\u2014never even your IP</li>\n+          <li>No email required to sign up</li>\n+          <li>Multi-national, open community</li>\n+          <li>Hacking encouraged!</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Customization &amp; Deployment</h3>\n+        <ul>\n+          <li>Full layout &amp; theme customization</li>\n+          <li>Install as a PWA on your phone</li>\n+          <li>Optionally self-host: <code>pip install snek</code>, zero config</li>\n+        </ul>\n+      </div>\n+    </section>\n+\n+    <section id=\"signup\" style=\"text-align:center; margin:4rem 0;\">\n+      <h2>Ready to join?</h2>\n+      <p>No email. No logs. Just sign up, pick a username, and dive in!</p>\n+      <a href=\"/register\" class=\"btn\">Sign Up Now</a>\n+    </section>\n+\n+    <section id=\"selfhost\" style=\"text-align:center; margin-bottom:4rem;\">\n+      <h2>Self-Host in Seconds</h2>\n+      <p>Just run:</p>\n+snek serve\n+      </pre>\n+      <p>No configuration required\u2014it's that simple.</p>\n+    </section>\n+\n+  </main>\n+\n+  <footer>\n+    <p>&copy; 2025 Snek \u2013 Join our global community of developers, testers &amp; AI enthusiasts.</p>\n+  </footer>\n+\n </body>\n </html>"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor chat input component with auto-completion and live typing support", "commit": "48c3daf3983e3b6e04a0c5888febceb69db9d661", "diff": "commit 48c3daf3983e3b6e04a0c5888febceb69db9d661\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 00:54:15 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex c1d767d..2d0914e 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -1,69 +1,234 @@\n-\n-\n-\n-\n-class ChatInputElement extends HTMLElement {\n-  _chatWindow = null \n-  constructor() {\n-    super();\n-    this.attachShadow({ mode: 'open' });\n-    this.component = document.createElement('div');\n-    this.shadowRoot.appendChild(this.component);\n-  }\n-  set chatWindow(value){\n-    this._chatWindow = value \n-\n-  }\n-  get chatWindow(){\n-    return this._chatWindow \n-  }\n-  get channelUid() {\n-    return this.chatWindow.channel.uid\n-  }\n-  connectedCallback() {\n-    const link = document.createElement('link');\n-    link.rel = 'stylesheet';\n-    link.href = '/base.css';\n-    this.component.appendChild(link);\n-\n-    this.container = document.createElement('div');\n-    this.container.classList.add('chat-input');\n-    this.container.innerHTML = `\n-      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-      <upload-button></upload-button>\n-    `;\n-    this.textBox = this.container.querySelector('textarea');\n-    this.uploadButton = this.container.querySelector('upload-button');\n-    this.uploadButton.chatInput = this \n-    this.textBox.addEventListener('input', (e) => {\n-      this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n-      const message = e.target.value;\n-      const button = this.container.querySelector('button');\n-      button.disabled = !message;\n-    });\n-\n-    this.textBox.addEventListener('change', (e) => {\n-      e.preventDefault();\n-      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n-      console.error(e.target.value);\n-    });\n-\n-    this.textBox.addEventListener('keydown', (e) => {\n-      if (e.key === 'Enter' && !e.shiftKey) {\n-        e.preventDefault();\n-        const message = e.target.value.trim();\n-        if (!message) return;\n-        this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));\n-        e.target.value = '';\n-      }\n-    });\n-\n-    this.component.appendChild(this.container);\n-  }\n+\n+import { app } from '../app.js';\n+\n+class ChatInputComponent extends HTMLElement {\n+    autoCompletions = {\n+        'example 1': () => {\n+\n+        },\n+        'example 2': () => {\n+\n+        }\n+    }\n+\n+    constructor() {\n+        super();\n+        this.lastUpdateEvent = new Date();\n+        this.textarea = document.createElement(\"textarea\");\n+        this._value = \"\";\n+        this.value = this.getAttribute(\"value\") || \"\";\n+        this.previousValue = this.value;\n+        this.lastChange = new Date();\n+        this.changed = false;\n+    }\n+\n+    get value() {\n+        return this._value;\n+    }\n+\n+    set value(value) {\n+        this._value = value || \"\";\n+        this.textarea.value = this._value;\n+    }\n+\n+    resolveAutoComplete() {\n+        let count = 0;\n+        let value = null;\n+        Object.keys(this.autoCompletions).forEach((key) => {\n+            if (key.startsWith(this.value)) {\n+                count++;\n+                value = key;\n+            }\n+        });\n+        if (count == 1)\n+            return value;\n+        return null;\n+    }\n+\n+    isActive() {\n+        return document.activeElement === this.textarea;\n+    }\n+\n+    focus() {\n+        this.textarea.focus();\n+    }\n+\n+    connectedCallback() {\n+        this.liveType = this.getAttribute(\"live-type\") === \"true\";\n+        this.liveTypeInterval = parseInt(this.getAttribute(\"live-type-interval\")) || 3;\n+        this.channelUid = this.getAttribute(\"channel\");\n+        this.messageUid = null;\n+\n+        this.classList.add(\"chat-input\");\n+\n+        this.textarea.setAttribute(\"placeholder\", \"Type a message...\");\n+        this.textarea.setAttribute(\"rows\", \"2\");\n+\n+        this.appendChild(this.textarea);\n+\n+        this.uploadButton = document.createElement(\"upload-button\");\n+        this.uploadButton.setAttribute(\"channel\", this.channelUid);\n+        this.uploadButton.addEventListener(\"upload\", (e) => {\n+            this.dispatchEvent(new CustomEvent(\"upload\", e));\n+        });\n+        this.uploadButton.addEventListener(\"uploaded\", (e) => {\n+            this.dispatchEvent(new CustomEvent(\"uploaded\", e));\n+        });\n+\n+        this.appendChild(this.uploadButton);\n+\n+        this.textarea.addEventListener(\"keyup\", (e) => {\n+            if(e.key === 'Enter' && !e.shiftKey) {\n+                this.value = ''\n+                e.target.value = '';\n+                return \n+            }\n+            this.value = e.target.value;\n+            this.changed = true;\n+            this.update();\n+        });\n+\n+        this.textarea.addEventListener(\"keydown\", (e) => {\n+            this.value = e.target.value;\n+            if (e.key === \"Tab\") {\n+                e.preventDefault();\n+                let autoCompletion = this.resolveAutoComplete();\n+                if (autoCompletion) {\n+                    e.target.value = autoCompletion;\n+                    this.value = autoCompletion;\n+                    return;\n+                }\n+            }\n+            if (e.key === 'Enter' && !e.shiftKey) {\n+                e.preventDefault();\n+\n+                const message = e.target.value;\n+                this.messageUid = null;\n+                this.value = '';\n+                this.previousValue = '';\n+\n+                if (!message) {\n+                    return;\n+                }\n+\n+                let autoCompletion = this.autoCompletions[message];\n+                if (autoCompletion) {\n+                    this.value = '';\n+                    this.previousValue = '';\n+                    e.target.value = '';\n+                    autoCompletion();\n+                    return;\n+                }\n+\n+                e.target.value = '';\n+                this.value = '';\n+                this.messageUid = null;\n+                this.sendMessage(this.channelUid, message).then((uid) => {\n+                    this.messageUid = uid;\n+                });\n+            }\n+        });\n+\n+        this.changeInterval = setInterval(() => {\n+            if (!this.liveType) {\n+                return;\n+            }\n+            if (this.value !== this.previousValue) {\n+                if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) {\n+                    this.value = '';\n+                    this.previousValue = '';\n+                }\n+                this.lastChange = new Date();\n+            }\n+            this.update();\n+        }, 300);\n+\n+        this.addEventListener(\"upload\", (e) => {\n+            this.focus();\n+        });\n+        this.addEventListener(\"uploaded\", function (e) {\n+            let message = \"\";\n+            e.detail.files.forEach((file) => {\n+                message += `[${file.name}](/channel/attachment/${file.relative_url})`;\n+            });\n+            app.rpc.sendMessage(this.channelUid, message);\n+        });\n+    }\n+\n+    trackSecondsBetweenEvents(event1Time, event2Time) {\n+        const millisecondsDifference = event2Time.getTime() - event1Time.getTime();\n+        return millisecondsDifference / 1000;\n+    }\n+\n+    newMessage() {\n+        if (!this.messageUid) {\n+            this.messageUid = '?';\n+        }\n+\n+        this.sendMessage(this.channelUid, this.value).then((uid) => {\n+            this.messageUid = uid;\n+        });\n+    }\n+\n+    updateMessage() {\n+        if (this.value[0] == \"/\") {\n+            return false;\n+        }\n+        if (!this.messageUid) {\n+            this.newMessage();\n+            return false;\n+        }\n+        if (this.messageUid === '?') {\n+            return false;\n+        }\n+        if (typeof app !== \"undefined\" && app.rpc && typeof app.rpc.updateMessageText === \"function\") {\n+            app.rpc.updateMessageText(this.messageUid, this.value);\n+        }\n+    }\n+\n+    updateStatus() {\n+        if (this.liveType) {\n+            return;\n+        }\n+        if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {\n+            this.lastUpdateEvent = new Date();\n+            if (typeof app !== \"undefined\" && app.rpc && typeof app.rpc.set_typing === \"function\") {\n+                app.rpc.set_typing(this.channelUid);\n+            }\n+        }\n+    }\n+\n+    update() {\n+        const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval;\n+        const changed = (this.value !== this.previousValue);\n+\n+        if (changed || expired) {\n+            this.lastChange = new Date();\n+            this.updateStatus();\n+        }\n+\n+        this.previousValue = this.value;\n+\n+        if (this.liveType && expired) {\n+            this.value = \"\";\n+            this.previousValue = \"\";\n+            this.messageUid = null;\n+            return;\n+        }\n+\n+        if (changed) {\n+            if (this.liveType) {\n+                this.updateMessage();\n+            }\n+        }\n+    }\n+\n+    async sendMessage(channelUid, value) {\n+        if (!value.trim()) {\n+            return null;\n+        }\n+        return await app.rpc.sendMessage(channelUid, value);\n+    }\n }\n \n-customElements.define('chat-input', ChatInputElement);\n+customElements.define('chat-input', ChatInputComponent);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 7baa67a..596b5d1 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -18,6 +18,7 @@\n   <script src=\"/file-manager.js\" type=\"module\"></script>\n   <script src=\"/user-list.js\"></script>\n   <script src=\"/message-list.js\" type=\"module\"></script>\n+  <script src=\"/chat-input.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/user-list.css\">\n \n   <link rel=\"stylesheet\" href=\"/base.css\">\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 02e60df..94d0ac5 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,10 +4,6 @@\n \n {% block main %}\n \n-\n-\n-\n-\n <section class=\"chat-area\">\n     <message-list class=\"chat-messages\">\n     {% for message in messages %}\n@@ -16,10 +12,7 @@\n         {% endautoescape %}\n     {% endfor %}\n     </message-list>\n-    <div class=\"chat-input\">\n-        <textarea list=\"chat-input-autocomplete-items\" placeholder=\"Type a message...\" rows=\"2\" autocomplete=\"on\"></textarea>\n-        <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </div>\n+    <chat-input live-type=\"false\" channel=\"{{ channel.uid.value }}\"></chat-input>\n </section>\n {% include \"dialog_help.html\" %}\n {% include \"dialog_online.html\" %}\n@@ -27,11 +20,8 @@\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n     const channelUid = \"{{ channel.uid.value }}\";\n-\n-    function getInputField(){\n-        return document.querySelector(\"textarea\")\n-    }\n-    getInputField().autoComplete = {\n+    const chatInputField = document.querySelector(\"chat-input\");\n+    chatInputField.autoCompletions = {\n         \"/online\": () =>{\n             showOnline();\n         },\n@@ -39,117 +29,15 @@\n             document.querySelector(\".chat-messages\").innerHTML = '';\n         },\n         \"/live\": () =>{\n-            getInputField().liveType = !getInputField().liveType\n+            \n+            chatInputField.liveType = !chatInputField.liveType\n         },\n         \"/help\": () => {\n             showHelp();\n         }\n-    }\n-\n-\n-    function initInputField(textBox) {\n-        if(textBox.liveType == undefined){\n-            textBox.liveType = false\n-        }\n-        let typeTimeout = null;\n-        textBox.addEventListener('keydown',async (e) => {\n-            if(typeTimeout){\n-                clearTimeout(typeTimeout)\n-                typeTimeout = null\n-            }\n-            if(e.target.liveType){\n-                typeTimeout = setTimeout(()=>{\n-                    e.target.lastMessageUid = null\n-                    e.target.value = ''\n-                },3000)\n-            }\n-            if(e.key === \"ArrowUp\"){\n-                const value = findDivAboveText(e.target.value).querySelector('.text')\n-                e.target.value = value.textContent\n-                console.info(\"HIERR\")\n-                return\n-            }\n-            if (e.key === \"Tab\") {\n-\n-                const message = e.target.value.trim();\n-                if (!message) {\n-                    return\n-                }\n-                let autoCompleteHandler = null;\n-                Object.keys(e.target.autoComplete).forEach((key)=>{\n-                    if(key.startsWith(message)){\n-                        if(autoCompleteHandler){\n-                            return \n-                        }\n-                        autoCompleteHandler = key\n-                    }\n-                })\n-                if(autoCompleteHandler){\n-                    e.preventDefault();\n-                    e.target.value = autoCompleteHandler;\n-                    return\n-                }\n-            }\n-            if (e.key === 'Enter' && !e.shiftKey) {\n-                e.preventDefault();\n-                const message = e.target.value.trim();\n-                if (!message) {\n-                    return\n-                }\n-                let autoCompleteHandler = e.target.autoComplete[message]\n-                if(autoCompleteHandler){\n-                    const value = message;\n-                    e.target.value = '';\n-                    autoCompleteHandler(value)\n-                    return\n-                }\n-\n-                e.target.value = '';\n-                if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){\n-                        \n-                \n-                    app.rpc.updateMessageText(textBox.lastMessageUid, message)\n-                textBox.lastMessageUid = null\n-                    return \n-                }\n-\n-                const messageResponse = await app.rpc.sendMessage(channelUid, message);\n-                \n-\t    }else{\n-\t\tif(textBox.liveType){\n-            if(e.target.value.endsWith(\"\\n\") || e.target.value.endsWith(\" \")){\n-                return\n-            } \n-            if(e.target.value[0] == \"/\"){\n-                return\n-            }\n-            if(!textBox.lastMessageUid){\n-                textBox.lastMessageUid = '?'\n-                app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{\n-                    textBox.lastMessageUid = messageResponse\n-                })\n-            }\n-            if(textBox.lastMessageUid == '?'){\n-                return;\n-            }\n-            app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n-        }else{\n-            app.rpc.set_typing(channelUid)\n-        }\n-\n-        \n-\t    }\n-        });\n-        document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n-            getInputField().focus();\n-        })\n-        document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n-            let message = \"\"\n-            e.detail.files.forEach((file)=>{\n-                message += `[${file.name}](/channel/attachment/${file.relative_url})`\n-            })\n-            app.rpc.sendMessage(channelUid,message)\n-        })\n+     }\n+    \n+        const textBox = document.querySelector(\"chat-input\").textarea\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();\n@@ -168,7 +56,7 @@\n                 }\n \n                 if (dt.items.length > 0) {\n-                    const uploadButton = document.querySelector(\"upload-button\");\n+                    const uploadButton = chatInputField.uploadButton\n                     const input = uploadButton.shadowRoot.querySelector('.file-input')\n                     input.files = dt.files;\n \n@@ -187,7 +75,7 @@\n \n             const dt = e.dataTransfer;\n             if (dt.items.length > 0) {\n-                const uploadButton = document.querySelector(\"upload-button\");\n+                const uploadButton = chatInputField.uploadButton\n                 const input = uploadButton.shadowRoot.querySelector('.file-input')\n                 input.files = dt.files;\n \n@@ -197,13 +85,16 @@\n         chatInput.addEventListener(\"dragover\", async (e) => {\n             e.preventDefault();\n             e.dataTransfer.dropEffect = \"link\";\n+        \n+\n         })\n \n-        textBox.focus();\n-    }\n+            chatInputField.textarea.focus();\n+\n+    \n \n     function replyMessage(message) {\n-        const field = getInputField()\n+        const field = chatInputField\n         field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }\n@@ -294,8 +185,8 @@\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) {\n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-             const inputBox = document.querySelector(\".chat-input\");\n-             inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+             \n+             chatInputField.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }\n \n@@ -378,17 +269,17 @@\n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n                 setTimeout(() => {\n                     \n-                    getInputField().focus();\n+                    chatInputField.focus();\n                 },500)\n \n             }\n         }\n         if (event.shiftKey && event.key === 'G') {\n-            if(document.activeElement != getInputField()){\n+            if(chatInputField.isActive()){\n             \n                 updateLayout(true);\n                 setTimeout(() => {\n-                    getInputField().focus();\n+                    chatInputField.focus();\n                 },500)\n             }\n \n@@ -432,7 +323,6 @@\n         document.body.removeChild(overlay);\n       });\n     });\n-    initInputField(getInputField());\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Added star animation and sandbox page", "commit": "e79abf4a26454cddf766cd1ba138554817c820cb", "diff": "commit e79abf4a26454cddf766cd1ba138554817c820cb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 17:46:59 2025 +0200\n\n    Update stars.\n\ndiff --git a/src/snek/static/sandbox.css b/src/snek/static/sandbox.css\nnew file mode 100644\nindex 0000000..1419fe4\n--- /dev/null\n+++ b/src/snek/static/sandbox.css\n@@ -0,0 +1,28 @@\n+    .star {\n+      position: absolute;\n+      width: 2px;\n+      height: 2px;\n+      border-radius: 50%;\n+      opacity: 0;\n+      animation: twinkle ease-in-out infinite;\n+    }\n+\n+    @keyframes twinkle {\n+      0%, 100% { opacity: 0; }\n+      50%      { opacity: 1; }\n+    }\n+\n+    .content {\n+      position: relative;\n+      z-index: 1;\n+      font-family: sans-serif;\n+      text-align: center;\n+      top: 40%;\n+      transform: translateY(-40%);\n+    }\n+\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 596b5d1..ceef196 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -19,6 +19,7 @@\n   <script src=\"/user-list.js\"></script>\n   <script src=\"/message-list.js\" type=\"module\"></script>\n   <script src=\"/chat-input.js\" type=\"module\"></script>\n+  <link rel=\"stylesheet\" href=\"/sandbox.css\">\n   <link rel=\"stylesheet\" href=\"/user-list.css\">\n \n   <link rel=\"stylesheet\" href=\"/base.css\">\n@@ -78,5 +79,6 @@ let installPrompt = null\n     \n         ;\n     </script>\n+    {% include \"sandbox.html\" %}\n </body>\n </html>\ndiff --git a/src/snek/templates/sandbox.html b/src/snek/templates/sandbox.html\nnew file mode 100644\nindex 0000000..6fd9b3f\n--- /dev/null\n+++ b/src/snek/templates/sandbox.html\n@@ -0,0 +1,31 @@\n+\n+\t\t\t\t\t\t\t<script>\n+                            \t\n+    const STAR_COUNT = 200;\n+    const body = document.body;\n+\n+    for (let i = 0; i < STAR_COUNT; i++) {\n+      const star = document.createElement('div');\n+      star.classList.add('star');\n+\n+      star.style.left  = Math.random() * 100 + '%';\n+      star.style.top   = Math.random() * 100 + '%';\n+\n+      star.style.width  = size + 'px';\n+      star.style.height = size + 'px';\n+\n+      star.style.animationDuration = duration + 's';\n+      star.style.animationDelay    = delay + 's';\n+\n+      body.appendChild(star);\n+    }\n+  \n+\n+\t\t\t\t\t\t\t</script>"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic files and structure", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f", "diff": "commit 66f89429366042c77599f3a9b8c1a7aecf976a4f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 17 23:06:17 2025 +0100\n\n    Initial commit.\n\ndiff --git a/.gitignore b/.gitignore\nnew file mode 100644\nindex 0000000..8cb2598\n--- /dev/null\n+++ b/.gitignore\n@@ -0,0 +1,166 @@\n+.vscode\n+.history\n+*.db*\n+\n+__pycache__/\n+*.py[cod]\n+*$py.class\n+\n+*.so\n+\n+.Python\n+build/\n+develop-eggs/\n+dist/\n+downloads/\n+eggs/\n+.eggs/\n+lib/\n+lib64/\n+parts/\n+sdist/\n+var/\n+wheels/\n+share/python-wheels/\n+*.egg-info/\n+.installed.cfg\n+*.egg\n+MANIFEST\n+\n+*.manifest\n+*.spec\n+\n+pip-log.txt\n+pip-delete-this-directory.txt\n+\n+htmlcov/\n+.tox/\n+.nox/\n+.coverage\n+.coverage.*\n+.cache\n+nosetests.xml\n+coverage.xml\n+*.cover\n+*.py,cover\n+.hypothesis/\n+.pytest_cache/\n+cover/\n+\n+*.mo\n+*.pot\n+\n+*.log\n+local_settings.py\n+db.sqlite3\n+db.sqlite3-journal\n+\n+instance/\n+.webassets-cache\n+\n+.scrapy\n+\n+docs/_build/\n+\n+.pybuilder/\n+target/\n+\n+.ipynb_checkpoints\n+\n+profile_default/\n+ipython_config.py\n+\n+\n+\n+\n+.pdm.toml\n+\n+__pypackages__/\n+\n+celerybeat-schedule\n+celerybeat.pid\n+\n+*.sage.py\n+\n+.env\n+.venv\n+env/\n+venv/\n+ENV/\n+env.bak/\n+venv.bak/\n+\n+.spyderproject\n+.spyproject\n+\n+.ropeproject\n+\n+/site\n+\n+.mypy_cache/\n+.dmypy.json\n+dmypy.json\n+\n+.pyre/\n+\n+.pytype/\n+\n+cython_debug/\n+\n+\ndiff --git a/Makefile b/Makefile\nnew file mode 100644\nindex 0000000..d41b81a\n--- /dev/null\n+++ b/Makefile\n@@ -0,0 +1,13 @@\n+PYTHON=./.venv/bin/python \n+PIP=./.venv/bin/pip \n+APP=./venv/bin/snek.serve\n+PORT = 8081\n+\n+\n+install:\n+\tpython3 -m venv .venv \n+\t$(PIP) install -e .\n+\n+run:\n+\t$(APP) --port=$(PORT)\n+\t\ndiff --git a/README.md b/README.md\nnew file mode 100644\nindex 0000000..b665fb1\n--- /dev/null\n+++ b/README.md\n@@ -0,0 +1,4 @@\n+\n+Matrix bot written in Python that says boeh everytime that Joe talks. He knows why.\n\\ No newline at end of file\ndiff --git a/pyproject.toml b/pyproject.toml\nnew file mode 100644\nindex 0000000..07de284\n--- /dev/null\n+++ b/pyproject.toml\n@@ -0,0 +1,3 @@\n+[build-system]\n+requires = [\"setuptools\", \"wheel\"]\n+build-backend = \"setuptools.build_meta\"\n\\ No newline at end of file\ndiff --git a/setup.cfg b/setup.cfg\nnew file mode 100644\nindex 0000000..bb6480d\n--- /dev/null\n+++ b/setup.cfg\n@@ -0,0 +1,24 @@\n+[metadata]\n+name = snek\n+version = 1.0.0\n+description = Snek chat server \n+author = retoor\n+author_email = retoor@molodetz.nl\n+license = MIT\n+long_description = file: README.md\n+long_description_content_type = text/markdown\n+\n+[options]\n+packages = find:\n+package_dir =\n+    = src\n+python_requires = >=3.7\n+install_requires =\n+\n+[options.packages.find]\n+where = src\n+\n+[options.entry_points]\n+console_scripts =\n+    snek.serve = snek.server:cli\ndiff --git a/src/snek/app.py b/src/snek/app.py\nnew file mode 100644\nindex 0000000..feb2fc0\n--- /dev/null\n+++ b/src/snek/app.py\n@@ -0,0 +1,20 @@\n+from app.app import Application as BaseApplication\n+from snek.forms import RegisterForm\n+from aiohttp import web\n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.router.add_get(\"/register\", self.handle_register)\n+        self.router.add_post(\"/register\", self.handle_register)\n+\n+    async def handle_register(self, request):\n+        if request.method == \"GET\":\n+            return web.json_response({\"form\": RegisterForm().to_json()})\n+        elif request.method == \"POST\":\n+            return self.render(\"register.html\")\n+\n+if __name__ == '__main__':\n+    app = Application()\n+    web.run_app(app,port=8081,host=\"0.0.0.0\")\ndiff --git a/src/snek/forms.py b/src/snek/forms.py\nnew file mode 100644\nindex 0000000..f4a7b24\n--- /dev/null\n+++ b/src/snek/forms.py\n@@ -0,0 +1,40 @@\n+from snek import models \n+\n+class FormElement(models.ModelField):\n+\n+    def __init__(self,html_type, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+        self.html_type = html_type \n+\n+    def to_json(self):\n+        data = super().to_json()\n+        data[\"html_type\"] = self.html_type\n+        data[\"place_holder\"] = self.place_holder\n+        return data \n+\n+class Form(models.BaseModel):\n+    pass\n+\n+class RegisterForm(Form):\n+\n+    username = FormElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        html_type=\"text\"\n+    )\n+    email = FormElement(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        html_type=\"email\"\n+    )\n+    password = FormElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",html_type=\"password\")\n+\n+\n+\ndiff --git a/src/snek/models.py b/src/snek/models.py\nnew file mode 100644\nindex 0000000..ec5d9ce\n--- /dev/null\n+++ b/src/snek/models.py\n@@ -0,0 +1,263 @@\n+import re\n+import uuid\n+import json \n+from datetime import datetime , timezone \n+from collections import OrderedDict\n+import copy \n+\n+TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n+\n+def now():\n+    return str(datetime.now(timezone.utc))\n+\n+def add_attrs(**kwargs):\n+    def decorator(func):\n+        for key, value in kwargs.items():\n+            setattr(func, key, value)\n+\n+        return func\n+    return decorator\n+\n+def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**kwargs):\n+        def decorator(func):\n+            return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n+\n+class Validator:\n+\n+    @property\n+    def value(self):\n+        return self._value \n+\n+    @value.setter \n+    def value(self,val):\n+        self._value = json.loads(json.dumps(val,default=str))\n+\n+    @property\n+    def initial_value(self):\n+        return None\n+\n+    def custom_validation(self):\n+        return True\n+\n+    def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+        self.required = required \n+        self.min_num = min_num \n+        self.max_num = max_num\n+        self.min_length = min_length \n+        self.max_length = max_length \n+        self.regex = regex \n+        self._value = None \n+        self.value = value \n+        self.type = kind\n+        self.help_text = help_text \n+        self.__dict__.update(kwargs)\n+    @property \n+    def errors(self):\n+        error_list = []\n+        if self.value is None and self.required:\n+            error_list.append(\"Field is required.\")\n+            return error_list \n+        \n+        if self.value is None:\n+            return error_list \n+\n+        if self.type == float or self.type == int:\n+            if self.min_num is not None and self.value < self.min_num:\n+                error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n+            if self.max_num is not None and self.value > self.max_num:\n+                error_list.append(\"Field should be maximal {}.\".format(self.max_num))\n+        if self.min_length is not None and len(self.value) < self.min_length:\n+            error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n+        if self.max_length is not None and len(self.value) > self.max_length:\n+            error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n+        if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\".format(self.regex))\n+        if not self.type is None and type(self.value) != self.type:\n+            error_list.append(\"Invalid type. It is supposed to be {}.\".format(self.type))\n+        return error_list \n+        \n+    def validate(self):\n+        if self.errors:\n+            raise ValueError(\"\\n\", self.errors)\n+        return True\n+\n+    @property\n+    def is_valid(self):\n+        try:\n+            self.validate()\n+            return True\n+        except ValueError:\n+            return False\n+\n+    def to_json(self):\n+        return {\n+            \"required\": self.required,\n+            \"min_num\": self.min_num,\n+            \"max_num\": self.max_num,\n+            \"min_length\": self.min_length,\n+            \"max_length\": self.max_length,\n+            \"regex\": self.regex,\n+            \"value\": self.value,\n+            \"type\": self.type,\n+            \"help_text\": self.help_text,\n+            \"errors\": self.errors,\n+            \"is_valid\": self.is_valid\n+        }\n+\n+class ModelField(Validator):\n+    def __init__(self,name=None,save=True, *args, **kwargs):\n+        self.name = name \n+        \n+        self.save = save\n+        super().__init__(*args, **kwargs)\n+\n+\n+class CreatedField(ModelField):\n+    \n+    @property\n+    def initial_value(self):\n+        return now()\n+\n+    def update(self):\n+        if not self.value:\n+            self.value = now()\n+\n+class UpdatedField(ModelField):\n+\n+    def update(self):\n+        self.value = now()\n+\n+class DeletedField(ModelField):\n+\n+    def update(self):\n+        self.value = now()\n+\n+class UUIDField(ModelField):\n+    \n+    @property \n+    def initial_value(self):\n+        return str(uuid.uuid4())\n+\n+\n+class BaseModel:\n+    \n+    uid = UUIDField(name=\"uid\",required=True)\n+    created_at = CreatedField(name=\"created_at\",required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n+    updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n+    deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+\n+    def __init__(self, *args, **kwargs):\n+        print(self.__dict__)\n+        print(dir(self.__class__))\n+        for key in dir(self.__class__):\n+            obj = getattr(self.__class__,key)\n+\n+            if isinstance(obj,Validator):\n+                self.__dict__[key] = copy.deepcopy(obj)\n+                print(\"JAAA\")\n+                self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+\n+    def __setitem__(self, key, value):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            obj.value = value \n+\n+    def __getattr__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            print(\"HPAPP\")\n+            return obj.value \n+        return obj\n+\n+\n+    def __getitem__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj,Validator):\n+            return obj.value \n+\n+    def __setattr__(self, key, value):\n+        obj = getattr(self,key)\n+        if isinstance(obj,Validator):\n+            obj.value = value\n+        else:\n+            setattr(self,key,value)\n+    @property \n+    def record(self):\n+        obj = self.to_json()\n+        record = {}\n+        for key,value in obj.items():\n+            if getattr(self,key).save:\n+                record[key] = value.get('value')\n+        return record\n+\n+    def to_json(self,encode=False):\n+        model_data = OrderedDict({\n+            \"uid\": self.uid.value,\n+            \"created_at\": self.created_at.value,\n+            \"updated_at\": self.updated_at.value,\n+            \"deleted_at\": self.deleted_at.value\n+        })\n+        for key,value in self.__dict__.items(): \n+            if key == \"record\":\n+                continue\n+            value = self.__dict__[key]\n+            if hasattr(value,\"value\"):\n+                model_data[key] = value.to_json()\n+        if encode:\n+            return json.dumps(model_data,indent=2)\n+        return model_data\n+\n+class FormElement(ModelField):\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+\n+\n+\n+class FormElement(ModelField):\n+\n+    def __init__(self,place_holder=None, *args, **kwargs): \n+        self.place_holder = place_holder \n+        super().__init__(*args, **kwargs)\n+\n+\n+    def to_json(self):\n+        data = super().to_json()\n+        data[\"name\"] = self.name \n+        data[\"place_holder\"] = self.place_holder\n+        return data \n+\n+\n+\n+\n+class TestModel(BaseModel):\n+\n+    first_name = FormElement(name=\"first_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"First name\")\n+    last_name = FormElement(name=\"last_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Last name\")\n+    email = FormElement(name=\"email\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")  \n+\n+class Form:\n+    username = FormElement(required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Username\")\n+    email = FormElement(required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")\n+    def __init__(self, *args, **kwargs):\n+        self.place_holder = kwargs.pop(\"place_holder\",None) \n+\n+\n+if __name__ == \"__main__\":\n+    model = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"n9K9p@example.com\",password=\"Password123\")\n+    model2 = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"ddd\",password=\"zzz\")\n+    model.first_name = \"AAA\"\n+    print(model.first_name)\n+    print(model.first_name.value)\n+    \n+    print(model.first_name)\n+    print(model.first_name.value)\n+    print(model.to_json(True))\n+    print(model2.to_json(True))\n+    print(model2.record)"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with basic description and setup instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62", "diff": "commit 46a27405aeb8ec426fd1c686a2c090f9fe9c0e62\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 17 23:09:46 2025 +0100\n\n    Initial commit.\n\ndiff --git a/README.md b/README.md\nindex b665fb1..df001ee 100644\n--- a/README.md\n+++ b/README.md\n@@ -1,4 +1,10 @@\n \n-Matrix bot written in Python that says boeh everytime that Joe talks. He knows why.\n\\ No newline at end of file\n+This is a slack like chat application with focus on performance. Other slack-like applications became too heavy. My RocketChat just had an 'frontend' crash. Just an error from the system itself like it's the most normal thing in the world.\n+\n+At this point, there's nothing officially running but what you can do:\n+* Install the project: `make install`\n+* Run part of the project: `./venv/bin/python -m snek.app`\n+* Run other part of the project: `./venv/bin/python -m snek.models`"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added basic login and register pages with styling.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0", "diff": "commit a7446d131413da9f013a56d3541192d8ab1e22b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 18 13:21:38 2025 +0100\n\n    Progress.\n\ndiff --git a/.gitignore b/.gitignore\nindex 8cb2598..3747073 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,7 +1,7 @@\n .vscode\n .history\n *.db*\n-\n+*.png\n __pycache__/\ndiff --git a/Dockerfile b/Dockerfile\nnew file mode 100644\nindex 0000000..76436ba\n--- /dev/null\n+++ b/Dockerfile\n@@ -0,0 +1,40 @@\n+FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n+FROM python:3.10-alpine\n+WORKDIR /code\n+ENV FLASK_APP=app.py\n+ENV FLASK_RUN_HOST=0.0.0.0\n+RUN apk add --no-cache gcc musl-dev linux-headers git\n+\n+RUN apk add --no-cache \\\n+    libstdc++ \\\n+    libx11 \\\n+    libxrender \\\n+    libxext \\\n+    libssl3 \\\n+    ca-certificates \\\n+    fontconfig \\\n+    freetype \\\n+    ttf-dejavu \\\n+    ttf-droid \\\n+    ttf-freefont \\\n+    ttf-liberation \\\n+  && apk add --no-cache --virtual .build-deps \\\n+    msttcorefonts-installer \\\n+  && update-ms-fonts \\\n+  && fc-cache -f \\\n+  && apk del .build-deps\n+COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n+COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n+COPY setup.cfg setup.cfg \n+COPY pyproject.toml pyproject.toml \n+COPY src src\n+RUN pip install --upgrade pip\n+RUN pip install -e .\n+EXPOSE 8081\n+\n+CMD [\"gunicorn\", \"-w\", \"10\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\ndiff --git a/Makefile b/Makefile\nindex d41b81a..65c58fa 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -1,6 +1,8 @@\n PYTHON=./.venv/bin/python \n PIP=./.venv/bin/pip \n-APP=./venv/bin/snek.serve\n+APP=./.venv/bin/snek.serve\n+GUNICORN=./.venv/bin/gunicorn\n+GUNICORN_WORKERS = 1\n PORT = 8081\n \n \n@@ -9,5 +11,5 @@ install:\n \t$(PIP) install -e .\n \n run:\n-\t$(APP) --port=$(PORT)\n+\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/cache/crc321300331366.cache b/cache/crc321300331366.cache\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/cache/crc322507170282.cache b/cache/crc322507170282.cache\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/compose.yml b/compose.yml\nnew file mode 100644\nindex 0000000..9186108\n--- /dev/null\n+++ b/compose.yml\n@@ -0,0 +1,12 @@\n+services:\n+  snek:\n+    build: .\n+    ports:\n+      - \"8081:8081\"\n+    volumes:\n+      - ./:/code\n+    develop:\n+      watch:\n+        - action: sync\n+          path: .\n+          target: /code\n\\ No newline at end of file\ndiff --git a/setup.cfg b/setup.cfg\nindex bb6480d..ca8353d 100644\n--- a/setup.cfg\n+++ b/setup.cfg\n@@ -15,6 +15,10 @@ package_dir =\n python_requires = >=3.7\n install_requires =\n+    beautifulsoup4\n+    gunicorn\n+    imgkit\n+    wkhtmltopdf\n \n [options.packages.find]\n where = src\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex feb2fc0..97fbb96 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,20 +1,60 @@\n from app.app import Application as BaseApplication\n from snek.forms import RegisterForm\n from aiohttp import web\n+import aiohttp \n+import pathlib\n+from snek import http \n+from snek.middleware import cors_allow_middleware,cors_middleware\n+\n \n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n+        middlewares = [\n+            cors_middleware,\n+            web.normalize_path_middleware(merge_slashes=True)\n+        ]\n+        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        super().__init__(middlewares=middlewares, template_path=self.template_path,*args, **kwargs)\n+        self.router.add_static(\"/\",pathlib.Path(__file__).parent.joinpath(\"static\"),name=\"static\",show_index=True)\n         self.router.add_get(\"/register\", self.handle_register)\n+        self.router.add_get(\"/login\", self.handle_login)\n+        self.router.add_get(\"/test\", self.handle_test)\n         self.router.add_post(\"/register\", self.handle_register)\n+        self.router.add_get(\"/http-get\",self.handle_http_get)\n+        self.router.add_get(\"/http-photo\",self.handle_http_photo)\n+\n+    async def handle_test(self,request):\n+\n+        return await self.render_template(\"test.html\",request,context={\"name\":\"retoor\"})\n+\n+    async def handle_http_get(self, request:web.Request):\n+        url = request.query.get(\"url\")\n+        content = await http.get(url)\n+        return web.Response(body=content)\n+\n+    async def handle_http_photo(self, request):\n+        url = request.query.get(\"url\")\n+        path = await http.create_site_photo(url)\n+        return web.Response(body=path.read_bytes(),headers={\n+            \"Content-Type\": \"image/png\"\n+        })\n+\n+    async def handle_login(self, request):\n+        if request.method == \"GET\":\n+        elif request.method == \"POST\":\n+        \n \n     async def handle_register(self, request):\n         if request.method == \"GET\":\n-            return web.json_response({\"form\": RegisterForm().to_json()})\n         elif request.method == \"POST\":\n             return self.render(\"register.html\")\n \n+app = Application()\n+\n if __name__ == '__main__':\n-    app = Application()\n+\n     web.run_app(app,port=8081,host=\"0.0.0.0\")\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nnew file mode 100644\nindex 0000000..4055bbd\n--- /dev/null\n+++ b/src/snek/gunicorn.py\n@@ -0,0 +1,3 @@\n+from snek.app import app \n+\n+application = app\ndiff --git a/src/snek/http.py b/src/snek/http.py\nnew file mode 100644\nindex 0000000..0b16bee\n--- /dev/null\n+++ b/src/snek/http.py\n@@ -0,0 +1,83 @@\n+from aiohttp import web \n+import aiohttp \n+from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n+from urllib.parse import urljoin\n+import pathlib \n+import uuid \n+import imgkit \n+import asyncio\n+import zlib\n+import io \n+\n+async def crc32(data):\n+    try:\n+        data = data.encode()\n+    except:\n+        pass \n+    result = \"crc32\" + str(zlib.crc32(data))\n+    return result \n+\n+async def get_file(name,suffix=\".cache\"):\n+    name = await crc32(name)\n+    path = pathlib.Path(\".\").joinpath(\"cache\")\n+    if not path.exists():\n+        path.mkdir(parents=True,exist_ok=True)\n+    path = path.joinpath(name + suffix)\n+    return path\n+\n+\n+\n+async def public_touch(name=None):\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4())+name)\n+    path.open(\"wb\").close()\n+    return path \n+\n+async def create_site_photo(url):\n+    loop = asyncio.get_event_loop()\n+    if not url.startswith(\"https\"):\n+    output_path = await get_file(\"site-screenshot-\" + url,\".png\")\n+    \n+    if output_path.exists():\n+        return output_path\n+    output_path.touch()\n+    def make_photo():\n+        imgkit.from_url(url, output_path.absolute())\n+        return output_path \n+\n+    return await loop.run_in_executor(None,make_photo)\n+\n+async def repair_links(base_url, html_content):\n+    soup = BeautifulSoup(html_content, \"html.parser\")\n+    for tag in soup.find_all(['a', 'img', 'link']):\n+            tag['href'] = urljoin(base_url, tag['href'])\n+            tag['src'] = urljoin(base_url, tag['src'])\n+            print(\"Fixed: \",tag['src'])\n+    return soup.prettify()\n+\n+async def is_html_content(content: bytes):\n+    try:\n+        content = content.decode(errors='ignore')\n+    except:\n+        pass \n+    marks = ['<html','<img','<p','<span','<div']\n+    try:\n+        content = content.lower()\n+        for mark in marks:\n+            if mark in content:\n+                return True \n+    except Exception as ex:\n+        print(ex)\n+    return False \n+\n+@time_cache_async(120)\n+async def get(url):\n+    async with aiohttp.ClientSession() as session:\n+        response = await session.get(url)\n+        content = await response.text()\n+        if await is_html_content(content):\n+            content = (await repair_links(url,content)).encode()\n+        return content\n\\ No newline at end of file\ndiff --git a/src/snek/middleware.py b/src/snek/middleware.py\nnew file mode 100644\nindex 0000000..6b801ab\n--- /dev/null\n+++ b/src/snek/middleware.py\n@@ -0,0 +1,32 @@\n+from aiohttp import web \n+\n+@web.middleware\n+async def no_cors_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n+    return response\n+\n+@web.middleware\n+async def cors_allow_middleware(request ,handler):\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    return response \n+\n+@web.middleware\n+async def cors_middleware(request, handler):\n+    if request.method == \"OPTIONS\":\n+        response = web.Response()\n+        response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+        response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+        response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+        return response\n+\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    return response\n\\ No newline at end of file\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nnew file mode 100644\nindex 0000000..701beda\n--- /dev/null\n+++ b/src/snek/static/app.js\n@@ -0,0 +1,77 @@\n+\n+\n+class Message {\n+    uid = null \n+    author = null\n+    avatar = null \n+    text = null \n+    time = null\n+    constructor(uid,avatar,author,text,time){\n+        this.uid = uid \n+        this.avatar = avatar \n+        this.author = author \n+        this.text = text \n+        this.time = time \n+    }\n+    \n+    get links() {\n+        if(!this.text)\n+            return []\n+        let result = []\n+        for(let part in this.text.split(/[,; ]/)){\n+            if(part.startsWith(\"http\") || part.startsWith(\"www.\") || part.indexOf(\".com\") || part.indexOf(\".net\") || part.indexOf(\".io\") || part.indexOf(\".nl\")){\n+                result.push(part)\n+\n+            }\n+        }\n+        return result\n+    }\n+    get mentions() {\n+        if(!this.text)\n+            return []\n+        let result = []\n+        for(let part in this.text.split(/[,; ]/)){\n+            if(part.startsWith(\"@\")){\n+                result.push(part)\n+\n+            }\n+        }\n+        return result \n+    }\n+}\n+\n+\n+class Messages {\n+\n+\n+\n+}\n+\n+\n+\n+\n+class Room {\n+    name = null \n+    messages = []\n+    constructor(name){\n+        this.name = name \n+    }\n+    setMessages(list){\n+        \n+    }\n+\n+\n+}\n+\n+\n+class App {\n+    rooms = []\n+    constructor() {\n+        this.rooms.push(new Room(\"General\"))\n+\n+\n+    }\n+\n+\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.css b/src/snek/static/html_frame.css\nnew file mode 100644\nindex 0000000..6b64c76\n--- /dev/null\n+++ b/src/snek/static/html_frame.css\n@@ -0,0 +1,9 @@\n+.html-frame {\n+    width: 100px;\n+    height: 50px;\n+    position: relative;\n+    overflow: hidden;\n+    border: 1px solid black;\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.js b/src/snek/static/html_frame.js\nnew file mode 100644\nindex 0000000..19a0c34\n--- /dev/null\n+++ b/src/snek/static/html_frame.js\n@@ -0,0 +1,39 @@\n+class HTMLFrame extends HTMLElement {\n+    constructor() {\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.container = document.createElement('div');\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+        this.container.classList.add(\"html_frame\")\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)\n+        console.info(fullUrl)    \n+        this.fetchAndDisplayHtml(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No URL provided!\";\n+      }\n+    }\n+\n+    async fetchAndDisplayHtml(url) {\n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+        }\n+        const html = await response.text();\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+  }\n+\n+  customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/prachtig-gitter_like.html b/src/snek/static/prachtig-gitter_like.html\nnew file mode 100644\nindex 0000000..bb30554\n--- /dev/null\n+++ b/src/snek/static/prachtig-gitter_like.html\n@@ -0,0 +1,172 @@\n+* {\n+  margin: 0;\n+  padding: 0;\n+  box-sizing: border-box;\n+}\n+\n+body {\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+}\n+\n+header {\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n+}\n+\n+header .logo {\n+  font-size: 1.5em;\n+  font-weight: bold;\n+}\n+\n+header nav a {\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+header nav a:hover {\n+}\n+\n+main {\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n+}\n+\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+\n+.chat-area {\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+}\n+\n+.chat-header {\n+  padding: 10px 20px;\n+}\n+\n+.chat-header h2 {\n+  font-size: 1.2em;\n+}\n+\n+.chat-messages {\n+  flex: 1;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.chat-messages .message {\n+  margin-bottom: 15px;\n+}\n+\n+.chat-messages .message .author {\n+  font-weight: bold;\n+}\n+\n+.chat-messages .message .content {\n+  margin-left: 10px;\n+}\n+\n+.chat-input {\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n+}\n+\n+.chat-input textarea {\n+  flex: 1;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n+.chat-input button {\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n+}\n+\n+.chat-input button:hover {\n+}\n+\n+@media (max-width: 768px) {\n+  .sidebar {\n+    display: none;\n+  }\n+\n+  .chat-area {\n+    flex: 1;\n+  }\n+}\n+\ndiff --git a/src/snek/static/register.css b/src/snek/static/register.css\nnew file mode 100644\nindex 0000000..fc4ca0f\n--- /dev/null\n+++ b/src/snek/static/register.css\n@@ -0,0 +1,95 @@\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+  \n+  body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    height: 100vh;\n+  }\n+  \n+  .registration-container {\n+    border-radius: 10px;\n+    padding: 30px;\n+    width: 400px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+    text-align: center;\n+  }\n+  \n+  .registration-container h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+  }\n+  \n+  .registration-container input {\n+    width: 100%;\n+    padding: 10px;\n+    margin: 10px 0;\n+    border-radius: 5px;\n+    font-size: 1em;\n+  }\n+  \n+  .registration-container button {\n+    width: 100%;\n+    padding: 10px;\n+    border: none;\n+    border-radius: 5px;\n+    color: white;\n+    font-size: 1em;\n+    font-weight: bold;\n+    cursor: pointer;\n+    transition: background-color 0.3s;\n+  }\n+  \n+  .registration-container button:hover {\n+  }\n+  \n+  .registration-container a {\n+    text-decoration: none;\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  \n+  .registration-container a:hover {\n+  }\n+  \n+  .error {\n+    font-size: 0.9em;\n+    margin-top: 5px;\n+  }\n+  \n+  @media (max-width: 500px) {\n+    .registration-container {\n+      width: 90%;\n+    }\n+  }\n+  \n\\ No newline at end of file\ndiff --git a/src/snek/static/styles.css b/src/snek/static/styles.css\nnew file mode 100644\nindex 0000000..83c1fb1\n--- /dev/null\n+++ b/src/snek/static/styles.css\n@@ -0,0 +1,203 @@\n+* {\n+  margin: 0;\n+  padding: 0;\n+  box-sizing: border-box;\n+}\n+\n+body {\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+}\n+\n+header {\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n+}\n+\n+header .logo {\n+  font-size: 1.5em;\n+  font-weight: bold;\n+}\n+\n+header nav a {\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+header nav a:hover {\n+}\n+\n+main {\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n+}\n+\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+\n+.chat-area {\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+}\n+\n+.chat-header {\n+  padding: 10px 20px;\n+}\n+\n+.chat-header h2 {\n+  font-size: 1.2em;\n+}\n+\n+.chat-messages {\n+  flex: 1;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.chat-messages .message {\n+  display: flex;\n+  align-items: flex-start;\n+  margin-bottom: 15px;\n+  padding: 10px;\n+  border-radius: 8px;\n+  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);\n+}\n+\n+.chat-messages .message .avatar {\n+  width: 40px;\n+  height: 40px;\n+  border-radius: 50%;\n+  font-size: 1em;\n+  font-weight: bold;\n+  display: flex;\n+  justify-content: center;\n+  align-items: center;\n+  margin-right: 15px;\n+}\n+\n+.chat-messages .message .message-content {\n+  flex: 1;\n+}\n+\n+.chat-messages .message .message-content .author {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+}\n+\n+.chat-messages .message .message-content .text {\n+  margin-bottom: 5px;\n+}\n+\n+.chat-messages .message .message-content .time {\n+  font-size: 0.8em;\n+}\n+\n+.chat-input {\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n+}\n+\n+.chat-input textarea {\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n+.chat-input button {\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n+}\n+\n+.chat-input button:hover {\n+}\n+\n+@media (max-width: 768px) {\n+  .sidebar {\n+    display: none;\n+  }\n+\n+  .chat-area {\n+    flex: 1;\n+  }\n+}\n+\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nnew file mode 100644\nindex 0000000..d37f3fa\n--- /dev/null\n+++ b/src/snek/templates/login.html\n@@ -0,0 +1,20 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Register</title>\n+  <link rel=\"stylesheet\" href=\"register.css\">\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Login</h1>\n+    <form>\n+      <input type=\"text\" name=\"username\" placeholder=\"Username or password\" required>\n+      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n+      <button type=\"submit\">Create Account</button>\n+      <a href=\"/register\">Not having an account yet? Register here.</a>\n+    </form>\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/prachtig_gitter_like.html b/src/snek/templates/prachtig_gitter_like.html\nnew file mode 100644\nindex 0000000..cd2d863\n--- /dev/null\n+++ b/src/snek/templates/prachtig_gitter_like.html\n@@ -0,0 +1,51 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dark Themed Chat Application</title>\n+  <link rel=\"stylesheet\" href=\"styles.css\">\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Molodetz Chat</div>\n+    <nav>\n+    </nav>\n+  </header>\n+  <main>\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+      </ul>\n+    </aside>\n+    <section class=\"chat-area\">\n+      <div class=\"chat-header\">\n+        <h2>General</h2>\n+      </div>\n+      <div class=\"chat-messages\">\n+        <div class=\"message\">\n+          <span class=\"author\">Alice:</span>\n+          <span class=\"content\">Hello, everyone!</span>\n+        </div>\n+        <div class=\"message\">\n+          <span class=\"author\">Bob:</span>\n+          <span class=\"content\">Hi Alice! How are you?</span>\n+        </div>\n+      </div>\n+      <div class=\"chat-input\">\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <button>Send</button>\n+      </div>\n+    </section>\n+  </main>\n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nnew file mode 100644\nindex 0000000..da41629\n--- /dev/null\n+++ b/src/snek/templates/register.html\n@@ -0,0 +1,22 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Register</title>\n+  <link rel=\"stylesheet\" href=\"register.css\">\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Register</h1>\n+    <form>\n+      <input type=\"text\" name=\"username\" placeholder=\"Username\" required>\n+      <input type=\"email\" name=\"email\" placeholder=\"Email Address\" required>\n+      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n+      <input type=\"password\" name=\"confirm_password\" placeholder=\"Confirm Password\" required>\n+      <button type=\"submit\">Create Account</button>\n+    </form>\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/test.html b/src/snek/templates/test.html\nnew file mode 100644\nindex 0000000..c23103a\n--- /dev/null\n+++ b/src/snek/templates/test.html\n@@ -0,0 +1,63 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dark Themed Chat Application</title>\n+  <link rel=\"stylesheet\" href=\"styles.css\">\n+  <script src=\"/html_frame.js\"></script>\n+  <script src=\"/html_frame.css\"></script>\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Molodetz Chat</div>\n+    <nav>\n+    </nav>\n+  </header>\n+  <main>\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+      </ul>\n+    </aside>\n+    <section class=\"chat-area\">\n+      <div class=\"chat-header\">\n+        <h2>General</h2>\n+      </div>\n+      <div class=\"chat-messages\">\n+        <div class=\"message\">\n+          <div class=\"avatar\">A</div>\n+          <div class=\"message-content\">\n+            <div class=\"author\">Alice</div>\n+            <div class=\"text\">Hello, everyone!</div>\n+            <div class=\"time\">10:45 AM</div>\n+          </div>\n+        </div>\n+        <html-frame class=\"html-frame\" url=\"/register\"></html-frame>\n+        <div class=\"message\">\n+          <div class=\"avatar\">B</div>\n+          <div class=\"message-content\">\n+            <div class=\"author\">Bob</div>\n+            <div class=\"text\">Hi Alice! How are you?</div>\n+            <div class=\"time\">10:46 AM</div>\n+          </div>\n+        </div>\n+      </div>\n+      <div class=\"chat-input\">\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <button>Send</button>\n+      </div>\n+    </section>\n+  </main>\n+  \n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/test2.html b/src/snek/templates/test2.html\nnew file mode 100644\nindex 0000000..9aad83a\n--- /dev/null\n+++ b/src/snek/templates/test2.html\n@@ -0,0 +1,122 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Dynamic Form Component</title>\n+  <style>\n+    .form-container {\n+      max-width: 400px;\n+      margin: 20px auto;\n+      padding: 20px;\n+      border-radius: 5px;\n+    }\n+    .form-field {\n+      margin-bottom: 15px;\n+    }\n+    .form-field label {\n+      font-weight: bold;\n+      display: block;\n+      margin-bottom: 5px;\n+    }\n+    .form-field input {\n+      width: 100%;\n+      padding: 8px;\n+      box-sizing: border-box;\n+      border-radius: 3px;\n+    }\n+    .form-field .error {\n+      color: red;\n+      font-size: 0.9em;\n+      margin-top: 5px;\n+    }\n+  </style>\n+</head>\n+<body>\n+  <!-- Use the custom form component -->\n+  <dynamic-form></dynamic-form>\n+\n+  <script>\n+    class DynamicForm extends HTMLElement {\n+      constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+      }\n+\n+      connectedCallback() {\n+        const formData = {\n+          form: {\n+            uid: { required: true, value: \"e13ad3b7-20b2-4c1a-b74e-b8c7d1abd107\", html_type: \"text\", place_holder: \"UID\", is_valid: true },\n+            created_at: { required: true, value: \"2025-01-17 21:21:27.561769+00:00\", html_type: \"text\", place_holder: \"Created At\", is_valid: true },\n+            updated_at: { required: false, value: null, html_type: \"text\", place_holder: \"Updated At\", is_valid: true },\n+            deleted_at: { required: false, value: null, html_type: \"text\", place_holder: \"Deleted At\", is_valid: true },\n+            email: { required: true, value: null, html_type: \"email\", place_holder: \"Email address\", errors: [\"Field is required.\"], is_valid: false },\n+            password: { required: true, value: null, html_type: \"password\", place_holder: \"Password\", errors: [\"Field is required.\"], is_valid: false },\n+            username: { required: true, value: null, html_type: \"text\", place_holder: \"Username\", errors: [\"Field is required.\"], is_valid: false }\n+          }\n+        };\n+\n+        this.render(formData);\n+      }\n+\n+      render(data) {\n+        const form = data.form;\n+        const container = document.createElement('div');\n+        container.className = 'form-container';\n+\n+        const formElement = document.createElement('form');\n+\n+        Object.entries(form).forEach(([fieldName, fieldData]) => {\n+          const fieldContainer = document.createElement('div');\n+          fieldContainer.className = 'form-field';\n+\n+          const label = document.createElement('label');\n+          label.textContent = fieldName.replace(/_/g, ' ').toUpperCase();\n+          label.htmlFor = fieldName;\n+          fieldContainer.appendChild(label);\n+\n+          const input = document.createElement('input');\n+          input.type = fieldData.html_type || 'text';\n+          input.name = fieldName;\n+          input.value = fieldData.value || '';\n+          input.placeholder = fieldData.place_holder || '';\n+          input.required = fieldData.required || false;\n+\n+          fieldContainer.appendChild(input);\n+\n+          if (fieldData.errors && fieldData.errors.length > 0) {\n+            const errorDiv = document.createElement('div');\n+            errorDiv.className = 'error';\n+            errorDiv.textContent = fieldData.errors.join(', ');\n+            fieldContainer.appendChild(errorDiv);\n+          }\n+\n+          formElement.appendChild(fieldContainer);\n+        });\n+\n+        container.appendChild(formElement);\n+\n+        this.shadowRoot.appendChild(container);\n+      }\n+    }\n+\n+    customElements.define('dynamic-form', DynamicForm);\n+  </script>\n+</body>\n+</html>\n+"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a", "diff": "commit 2e3b85d7f739160783e7c5552f1306298047704a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 18 12:23:23 2025 +0000\n\n    Updated compose.yml\n\ndiff --git a/compose.yml b/compose.yml\nindex 9186108..776e60d 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -1,12 +1,8 @@\n services:\n   snek:\n     build: .\n+    restart: always\n     ports:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-    develop:\n-      watch:\n-        - action: sync\n-          path: .\n-          target: /code\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and introduce generic form components.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81", "diff": "commit ba83922660dade77dcb96e8ba9c73cfcba8c2b81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 03:28:43 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/prachtig-gitter_like.html b/.resources/prachtig-gitter_like.html\nsimilarity index 100%\nrename from src/snek/static/prachtig-gitter_like.html\nrename to .resources/prachtig-gitter_like.html\ndiff --git a/src/snek/templates/prachtig_gitter_like.html b/.resources/prachtig_gitter_like.html\nsimilarity index 100%\nrename from src/snek/templates/prachtig_gitter_like.html\nrename to .resources/prachtig_gitter_like.html\ndiff --git a/Dockerfile b/Dockerfile\nindex 76436ba..47c0ece 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -37,4 +37,5 @@ RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 8081\n \n-CMD [\"gunicorn\", \"-w\", \"10\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+python -m snek.app\ndiff --git a/LICENSE.txt b/LICENSE.txt\nnew file mode 100644\nindex 0000000..6b0da88\n--- /dev/null\n+++ b/LICENSE.txt\n@@ -0,0 +1,21 @@\n+MIT License\n+\n+Copyright (c) 2025 retoor\n+\n+Permission is hereby granted, free of charge, to any person obtaining a copy\n+of this software and associated documentation files (the \"Software\"), to deal\n+in the Software without restriction, including without limitation the rights\n+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n+copies of the Software, and to permit persons to whom the Software is\n+furnished to do so, subject to the following conditions:\n+\n+The above copyright notice and this permission notice shall be included in all\n+copies or substantial portions of the Software.\n+\n+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n+SOFTWARE.\ndiff --git a/Makefile b/Makefile\nindex 65c58fa..f153fc1 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -10,6 +10,8 @@ install:\n \tpython3 -m venv .venv \n \t$(PIP) install -e .\n \n+\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/compose.yml b/compose.yml\nindex 776e60d..3b1f650 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,3 +6,5 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+   \n\\ No newline at end of file\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 07de284..d98557e 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -1,3 +1,25 @@\n [build-system]\n requires = [\"setuptools\", \"wheel\"]\n-build-backend = \"setuptools.build_meta\"\n\\ No newline at end of file\n+build-backend = \"setuptools.build_meta\"\n+\n+[project]\n+name = \"Snek\"\n+version = \"1.0.0\"\n+readme = \"README.md\"\n+license = { file = \"LICENSE\", content-type=\"text/markdown\" }\n+description = \"Snek Chat Application by Molodetz\"\n+authors = [\n+    { name = \"retoor\", email = \"retoor@molodetz.nl\" }\n+]\n+keywords = [\"chat\", \"snek\", \"molodetz\"]\n+requires-python = \">=3.12\"\n+dependencies = [\n+    \"mkdocs>=1.4.0\",\n+    \"shed\",\n+    \"beautifulsoup4\",\n+    \"gunicorn\",\n+    \"imgkit\",\n+    \"wkhtmltopdf\"\n+]\n+\ndiff --git a/setup.cfg b/setup.cfg\nindex ca8353d..045fc92 100644\n--- a/setup.cfg\n+++ b/setup.cfg\n@@ -19,6 +19,7 @@ install_requires =\n     gunicorn\n     imgkit\n     wkhtmltopdf\n+    shed\n \n [options.packages.find]\n where = src\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 97fbb96..0e2ed63 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,10 +1,16 @@\n-from app.app import Application as BaseApplication\n-from snek.forms import RegisterForm\n-from aiohttp import web\n-import aiohttp \n import pathlib\n-from snek import http \n-from snek.middleware import cors_allow_middleware,cors_middleware\n+\n+from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n+from snek.system import http\n+from snek.system.middleware import cors_middleware\n+from snek.view.index import IndexView\n+from snek.view.login import LoginView\n+from snek.view.login_form import LoginFormView\n+from snek.view.register import RegisterView\n+from snek.view.register_form import RegisterFormView\n+from snek.view.view import WebView\n \n \n class Application(BaseApplication):\n@@ -12,23 +18,38 @@ class Application(BaseApplication):\n     def __init__(self, *args, **kwargs):\n         middlewares = [\n             cors_middleware,\n-            web.normalize_path_middleware(merge_slashes=True)\n+            web.normalize_path_middleware(merge_slashes=True),\n         ]\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n-        super().__init__(middlewares=middlewares, template_path=self.template_path,*args, **kwargs)\n-        self.router.add_static(\"/\",pathlib.Path(__file__).parent.joinpath(\"static\"),name=\"static\",show_index=True)\n-        self.router.add_get(\"/register\", self.handle_register)\n-        self.router.add_get(\"/login\", self.handle_login)\n-        self.router.add_get(\"/test\", self.handle_test)\n-        self.router.add_post(\"/register\", self.handle_register)\n-        self.router.add_get(\"/http-get\",self.handle_http_get)\n-        self.router.add_get(\"/http-photo\",self.handle_http_photo)\n+        super().__init__(\n+            middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n+        )\n+        self.setup_router()\n+\n+    def setup_router(self):\n+        self.router.add_get(\"/\", IndexView)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n+        self.router.add_view(\"/web\", WebView)\n+        self.router.add_view(\"/login\", LoginView)\n+        self.router.add_view(\"/login-form\", LoginFormView)\n+        self.router.add_view(\"/register\", RegisterView)\n+        \n+        self.router.add_view(\"/register-form\", RegisterFormView)\n+        self.router.add_get(\"/http-get\", self.handle_http_get)\n+        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n-    async def handle_test(self,request):\n+    async def handle_test(self, request):\n \n-        return await self.render_template(\"test.html\",request,context={\"name\":\"retoor\"})\n+        return await self.render_template(\n+            \"test.html\", request, context={\"name\": \"retoor\"}\n+        )\n \n-    async def handle_http_get(self, request:web.Request):\n+    async def handle_http_get(self, request: web.Request):\n         url = request.query.get(\"url\")\n         content = await http.get(url)\n         return web.Response(body=content)\n@@ -36,25 +57,13 @@ class Application(BaseApplication):\n     async def handle_http_photo(self, request):\n         url = request.query.get(\"url\")\n         path = await http.create_site_photo(url)\n-        return web.Response(body=path.read_bytes(),headers={\n-            \"Content-Type\": \"image/png\"\n-        })\n-\n-    async def handle_login(self, request):\n-        if request.method == \"GET\":\n-        elif request.method == \"POST\":\n-        \n+        return web.Response(\n+            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n+        )\n \n-    async def handle_register(self, request):\n-        if request.method == \"GET\":\n-        elif request.method == \"POST\":\n-            return self.render(\"register.html\")\n \n app = Application()\n \n-if __name__ == '__main__':\n+if __name__ == \"__main__\":\n \n-    web.run_app(app,port=8081,host=\"0.0.0.0\")\n+    web.run_app(app, port=8081, host=\"0.0.0.0\")\ndiff --git a/src/snek/form/__init__.py b/src/snek/form/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nnew file mode 100644\nindex 0000000..a87f7d3\n--- /dev/null\n+++ b/src/snek/form/login.py\n@@ -0,0 +1,24 @@\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class LoginForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Login\")\n+\n+    username = FormInputElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n+\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Login\",\n+        type=\"button\"\n+    )\n+\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nnew file mode 100644\nindex 0000000..60399fb\n--- /dev/null\n+++ b/src/snek/form/register.py\n@@ -0,0 +1,31 @@\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = FormInputElement(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n+\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+\ndiff --git a/src/snek/forms.py b/src/snek/forms.py\ndeleted file mode 100644\nindex f4a7b24..0000000\n--- a/src/snek/forms.py\n+++ /dev/null\n@@ -1,40 +0,0 @@\n-from snek import models \n-\n-class FormElement(models.ModelField):\n-\n-    def __init__(self,html_type, place_holder=None, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n-        self.place_holder = place_holder\n-        self.html_type = html_type \n-\n-    def to_json(self):\n-        data = super().to_json()\n-        data[\"html_type\"] = self.html_type\n-        data[\"place_holder\"] = self.place_holder\n-        return data \n-\n-class Form(models.BaseModel):\n-    pass\n-\n-class RegisterForm(Form):\n-\n-    username = FormElement(\n-        name=\"username\", \n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n-        place_holder=\"Username\",\n-        html_type=\"text\"\n-    )\n-    email = FormElement(\n-        name=\"email\",\n-        required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        html_type=\"email\"\n-    )\n-    password = FormElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",html_type=\"password\")\n-\n-\n-\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex 4055bbd..8583142 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,3 +1,3 @@\n-from snek.app import app \n+from snek.app import app\n \n application = app\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nnew file mode 100644\nindex 0000000..44553f8\n--- /dev/null\n+++ b/src/snek/model/user.py\n@@ -0,0 +1,19 @@\n+from snek.system.model import BaseModel,ModelField\n+\n+class User(BaseModel):\n+    \n+    username = ModelField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+    )\n+    email = ModelField(\n+        name=\"email\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n+    )\n+    password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n+\n+\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 701beda..f06ee24 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -57,13 +57,26 @@ class Room {\n         this.name = name \n     }\n     setMessages(list){\n-        \n+\n     }\n \n \n }\n \n \n+class InlineAppElement extends HTMLElement {\n+    \n+    constructor(){\n+        this.\n+    }\n+\n+}\n+\n+class Page {\n+    elements = []\n+\n+}\n+\n class App {\n     rooms = []\n     constructor() {\ndiff --git a/src/snek/static/styles.css b/src/snek/static/base.css\nsimilarity index 94%\nrename from src/snek/static/styles.css\nrename to src/snek/static/base.css\nindex 83c1fb1..363e4f1 100644\n--- a/src/snek/static/styles.css\n+++ b/src/snek/static/base.css\n@@ -1,11 +1,9 @@\n * {\n   margin: 0;\n   padding: 0;\n   box-sizing: border-box;\n }\n \n body {\n   font-family: Arial, sans-serif;\n@@ -16,7 +14,6 @@ body {\n   height: 100vh;\n }\n \n header {\n   padding: 10px 20px;\n@@ -43,14 +40,12 @@ header nav a:hover {\n }\n \n main {\n   display: flex;\n   flex: 1;\n   overflow: hidden;\n }\n \n .sidebar {\n   width: 250px;\n@@ -84,7 +79,6 @@ main {\n }\n \n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -103,7 +97,6 @@ main {\n }\n \n .chat-messages {\n   flex: 1;\n   padding: 20px;\n@@ -155,7 +148,6 @@ main {\n }\n \n .chat-input {\n   padding: 15px;\n@@ -190,7 +182,6 @@ main {\n }\n \n @media (max-width: 768px) {\n   .sidebar {\n     display: none;\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nnew file mode 100644\nindex 0000000..5407a3b\n--- /dev/null\n+++ b/src/snek/static/fancy-button.js\n@@ -0,0 +1,54 @@\n+\n+\n+class FancyButton extends HTMLElement {\n+    url = null\n+    type=\"button\"\n+    value = null\n+    constructor(){\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement('span')\n+        this.styleElement = document.createElement(\"style\")\n+        this.styleElement.innerHTML = `\n+        :root {\n+        width:100%;\n+            --width: 100%;\n+    }\n+        button {\n+            width: var(--width);\n+            min-width: 33%;\n+            padding: 10px;\n+            border: none;\n+            border-radius: 5px;\n+            color: white;\n+            font-size: 1em;\n+            font-weight: bold;\n+            cursor: pointer;\n+            transition: background-color 0.3s;\n+    }\n+        button:hover {\n+  }\n+    `\n+    this.container.appendChild(this.styleElement)\n+        this.buttonElement = document.createElement('button')\n+        this.container.appendChild(this.buttonElement)\n+        this.shadowRoot.appendChild(this.container)\n+    }\n+\n+    connectedCallback() {\n+        this.url = this.getAttribute('url');\n+        this.value = this.getAttribute('value')\n+        const me = this \n+        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n+        this.buttonElement.addEventListener(\"click\",()=>{\n+            if(me.url){\n+                window.location = me.url\n+            }\n+        })\n+    }\n+}\n+\n+customElements.define(\"fancy-button\",FancyButton)\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nnew file mode 100644\nindex 0000000..d593816\n--- /dev/null\n+++ b/src/snek/static/generic-form.css\n@@ -0,0 +1,100 @@\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+  \n+  body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    height: 100vh;\n+  }\n+\n+generic-form {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+\n+}\n+\n+.generic-form-container {\n+    \n+    border-radius: 10px;\n+    padding: 30px;\n+    width: 400px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+    text-align: center;\n+\n+}\n+\n+.generic-form-container h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+}\n+input {\n+\n+}\n+.generic-form-container generic-field {\n+    width: 100%;\n+    padding: 10px;\n+    margin: 10px 0;\n+    border-radius: 5px;\n+    font-size: 1em;\n+}\n+\n+.generic-form-container button {\n+    width: 100%;\n+    padding: 10px;\n+    border: none;\n+    border-radius: 5px;\n+    color: white;\n+    font-size: 1em;\n+    font-weight: bold;\n+    cursor: pointer;\n+    transition: background-color 0.3s;\n+}\n+\n+.generic-form-container button:hover {\n+}\n+\n+.generic-form-container a {\n+    text-decoration: none;\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+}\n+\n+.generic-form-container a:hover {\n+}\n+\n+\n+.error {\n+    font-size: 0.9em;\n+    margin-top: 5px;\n+}\n+\n+\n+@media (max-width: 500px) {\n+    .generic-form-container {\n+        width: 90%;\n+    }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nnew file mode 100644\nindex 0000000..11fea47\n--- /dev/null\n+++ b/src/snek/static/generic-form.js\n@@ -0,0 +1,321 @@\n+\n+class GenericField extends HTMLElement {\n+  form = null\n+  field = null \n+  inputElement = null\n+  footerElement = null \n+  action = null \n+  container = null\n+  styleElement = null\n+  name = null\n+    get value() {\n+      return this.inputElement.value\n+    }\n+    get type() {\n+\n+        return this.field.tag \n+    }\n+    set value(val) {\n+      val = val == null ? '' : val \n+      this.inputElement.value = val \n+      this.inputElement.setAttribute(\"value\", val)\n+    }\n+    setInvalid(){\n+      this.inputElement.classList.add(\"error\")\n+      this.inputElement.classList.remove(\"valid\")\n+    }\n+    setErrors(errors){\n+      if(errors.length)\n+        this.inputElement.setAttribute(\"title\", errors[0])\n+      else\n+       this.inputElement.setAttribute(\"title\",\"\")\n+    }\n+    setValid(){\n+      this.inputElement.classList.remove(\"error\")\n+      this.inputElement.classList.add(\"valid\")\n+    }\n+    constructor() {\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement('div')\n+        this.styleElement = document.createElement('style')\n+        this.styleElement.innerHTML = `\n+\n+            h1 {\n+              font-size: 2em;\n+              margin-bottom: 20px;\n+              margin-top: 0px;\n+            }\n+\n+            input {\n+                width: 90%;\n+                padding: 10px;\n+                margin: 10px 0;\n+                border-radius: 5px;\n+                font-size: 1em;\n+            }\n+\n+            button {\n+                width: 50%;\n+                padding: 10px;\n+                border: none;\n+               float: right;\n+                margin-top: 10px;\n+                margin-left: 10px;\n+                margin-right: 10px;\n+                border-radius: 5px;\n+                color: white;\n+                font-size: 1em;\n+                font-weight: bold;\n+                cursor: pointer;\n+                transition: background-color 0.3s;\n+                clear: both;\n+            }\n+\n+            button:hover {\n+            }\n+\n+            a {\n+                text-decoration: none;\n+                display: block;\n+                margin-top: 15px;\n+                font-size: 0.9em;\n+                transition: color 0.3s;\n+            }\n+\n+            a:hover {\n+            }\n+            .valid {\n+                border: 1px solid green;\n+                color:green;\n+                font-size: 0.9em;\n+                margin-top: 5px;\n+            }\n+            .error {\n+              border: 3px solid red;\n+                font-size: 0.9em;\n+                margin-top: 5px;\n+            }\n+            @media (max-width: 500px) {\n+                input {\n+                    width: 90%;\n+                }\n+            }    \n+            \n+            `\n+        this.container.appendChild(this.styleElement)\n+        \n+        this.shadowRoot.appendChild(this.container)\n+    }\n+    connectedCallback(){\n+\n+      this.updateAttributes() \n+       \n+    }\n+    setAttribute(name,value){\n+        this[name] = value \n+    }\n+    updateAttributes(){\n+      if(this.inputElement == null && this.field){\n+       this.inputElement = document.createElement(this.field.tag)\n+       if(this.field.tag == 'button'){\n+          if(this.field.value == \"submit\"){\n+          \n+          \n+          }\n+          this.action = this.field.value\n+       }\n+       this.inputElement.name = this.field.name \n+       this.name = this.inputElement.name\n+       const me = this\n+       this.inputElement.addEventListener(\"keyup\",(e)=>{\n+        if(e.key == 'Enter'){\n+            me.dispatchEvent(new Event(\"submit\"))\n+          }else if(me.field.value != e.target.value)\n+          {\n+            const event = new CustomEvent(\"change\", {detail:me,bubbles:true})\n+            me.dispatchEvent(event)\n+          }\n+       })\n+       this.inputElement.addEventListener(\"click\",(e)=>{\n+          const event = new CustomEvent(\"click\",{detail:me,bubbles:true})\n+         me.dispatchEvent(event)    \n+       })\n+        this.container.appendChild(this.inputElement)\n+\n+}\n+      if(!this.field){\n+        return\n+      }\n+        this.inputElement.setAttribute(\"type\",this.field.type == null ? 'input' : this.field.type)\n+        this.inputElement.setAttribute(\"name\",this.field.name  == null ? '' : this.field.name)\n+        \n+        if(this.field.text != null){\n+          this.inputElement.innerText = this.field.text \n+        }\n+        if(this.field.html != null){\n+          this.inputElement.innerHTML = this.field.html\n+        }\n+        if(this.field.class_name){\n+          this.inputElement.classList.add(this.field.class_name)\n+        }\n+        this.inputElement.setAttribute(\"tabindex\", this.field.index)\n+        this.inputElement.classList.add(this.field.name)\n+        this.value = this.field.value\n+        let place_holder = null \n+        if(this.field.place_holder)\n+            place_holder = this.field.place_holder\n+        if(this.field.required && place_holder){\n+          place_holder = place_holder\n+        }\n+        if(place_holder)\n+          this.field.place_holder = \"* \" + place_holder\n+        this.inputElement.setAttribute(\"placeholder\",place_holder)\n+        if(this.field.required)\n+            this.inputElement.setAttribute(\"required\",\"required\")\n+        else\n+            this.inputElement.removeAttribute(\"required\")\n+        if(!this.footerElement){\n+            this.footerElement = document.createElement('div')\n+            this.footerElement.style.clear = 'both'\n+            this.container.appendChild(this.footerElement)\n+        }\n+    }\n+}\n+\n+customElements.define('generic-field', GenericField);\n+\n+class GenericForm extends HTMLElement {\n+  fields = {}  \n+  form = {}\n+  constructor() {\n+\n+\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.styleElement = document.createElement(\"style\")\n+      this.styleElement.innerHTML = `\n+\n+      * {\n+          margin: 0;\n+          padding: 0;\n+          box-sizing: border-box;\n+          width:90%\n+\n+      }\n+\n+      div {\n+          \n+          border-radius: 10px;\n+          padding: 30px;\n+          width: 400px;\n+          box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+          text-align: center;\n+\n+      }\n+      @media (max-width: 500px) {\n+          form {\n+              width: 80%;\n+          }\n+      }`\n+      \n+      this.container = document.createElement('div');\n+      this.container.appendChild(this.styleElement)\n+      this.container.classList.add(\"generic-form-container\")\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadForm(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No URL provided!\";\n+      }\n+    }\n+\n+    async loadForm(url) {\n+      const me = this \n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+        }\n+        me.form = await response.json();\n+        \n+        let fields = Object.values(me.form.fields)\n+        \n+        fields = fields.sort((a,b)=>{\n+          console.info(a.index,b.index)\n+          return a.index - b.index\n+        })  \n+        fields.forEach(field=>{\n+          const fieldElement = document.createElement('generic-field')\n+          me.fields[field.name] = fieldElement \n+          fieldElement.setAttribute(\"form\", me)\n+          fieldElement.setAttribute(\"field\", field)\n+          me.container.appendChild(fieldElement)\n+          fieldElement.updateAttributes() \n+          fieldElement.addEventListener(\"change\",(e)=>{\n+            me.form.fields[e.detail.name].value = e.detail.value\n+          })\n+          fieldElement.addEventListener(\"click\",async (e)=>{\n+            if(e.detail.type == \"button\"){\n+              if(e.detail.value == \"submit\")\n+              {\n+                await me.validate()\n+              }\n+            }\n+              \n+          })\n+        })\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+    async validate(){\n+      const url = this.getAttribute(\"url\")\n+      const me = this\n+      const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n+      });\n+      const form = await response.json()\n+      Object.values(form.fields).forEach(field=>{\n+        if(!me.form.fields[field.name])\n+          return\n+          me.form.fields[field.name].is_valid = field.is_valid \n+        if(!field.is_valid){\n+          me.fields[field.name].setInvalid()\n+          me.fields[field.name].setErrors(field.errors)\n+          console.info(field.name,\"is invalid\")\n+        }else{\n+          me.fields[field.name].setValid()\n+        }\n+        me.fields[field.name].setAttribute(\"field\",field)\n+        me.fields[field.name].updateAttributes()\n+      })\n+      Object.values(form.fields).forEach(field=>{\n+        console.info(field.errors)\n+        me.fields[field.name].setErrors(field.errors)\n+      })\n+    }\n+  }\n+  customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.css b/src/snek/static/html-frame.css\nsimilarity index 53%\nrename from src/snek/static/html_frame.css\nrename to src/snek/static/html-frame.css\nindex 6b64c76..92a1f97 100644\n--- a/src/snek/static/html_frame.css\n+++ b/src/snek/static/html-frame.css\n@@ -1,9 +1,6 @@\n .html-frame {\n     width: 100px;\n     height: 50px;\n-    position: relative;\n     overflow: hidden;\n     border: 1px solid black;\n-\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/html_frame.js b/src/snek/static/html-frame.js\nsimilarity index 62%\nrename from src/snek/static/html_frame.js\nrename to src/snek/static/html-frame.js\nindex 19a0c34..22581ce 100644\n--- a/src/snek/static/html_frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -12,28 +12,25 @@ class HTMLFrame extends HTMLElement {\n       if (url) {\n         const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n         if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)\n-        console.info(fullUrl)    \n-        this.fetchAndDisplayHtml(fullUrl.toString());\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadAndRender(fullUrl.toString());\n       } else {\n-        this.container.textContent = \"No URL provided!\";\n+        this.container.textContent = \"No source URL!\";\n       }\n     }\n \n-    async fetchAndDisplayHtml(url) {\n+    async loadAndRender(url) {\n       try {\n         const response = await fetch(url);\n         if (!response.ok) {\n-          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+          throw new Error(`Error: ${response.status} ${response.statusText}`);\n         }\n         const html = await response.text();\n+        this.container.innerHTML = html;\n         \n     } catch (error) {\n         this.container.textContent = `Error: ${error.message}`;\n       }\n     }\n   }\n-\n   customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/register.css b/src/snek/static/register__.css\nsimilarity index 74%\nrename from src/snek/static/register.css\nrename to src/snek/static/register__.css\nindex fc4ca0f..57186b8 100644\n--- a/src/snek/static/register.css\n+++ b/src/snek/static/register__.css\n@@ -1,24 +1,12 @@\n+\n * {\n     margin: 0;\n     padding: 0;\n     box-sizing: border-box;\n   }\n   \n-  body {\n-    font-family: Arial, sans-serif;\n-    line-height: 1.5;\n-    display: flex;\n-    flex-direction: column;\n-    justify-content: center;\n-    align-items: center;\n-    height: 100vh;\n-  }\n   \n+  \n   .registration-container {\n     border-radius: 10px;\n@@ -26,16 +14,15 @@\n     width: 400px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n     text-align: center;\n+    left: calc(50%-200);\n   }\n   \n   .registration-container h1 {\n     font-size: 2em;\n     margin-bottom: 20px;\n   }\n   \n   .registration-container input {\n     width: 100%;\n     padding: 10px;\n@@ -47,7 +34,6 @@\n     font-size: 1em;\n   }\n   \n   .registration-container button {\n     width: 100%;\n     padding: 10px;\n@@ -65,7 +51,6 @@\n   }\n   \n   .registration-container a {\n     text-decoration: none;\n@@ -79,14 +64,11 @@\n   }\n   \n   .error {\n     font-size: 0.9em;\n     margin-top: 5px;\n   }\n-  \n   @media (max-width: 500px) {\n     .registration-container {\n       width: 90%;\ndiff --git a/src/snek/system/__init__.py b/src/snek/system/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nnew file mode 100644\nindex 0000000..68f7c0f\n--- /dev/null\n+++ b/src/snek/system/form.py\n@@ -0,0 +1,171 @@\n+from snek.system import model \n+\n+class HTMLElement(model.ModelField):\n+    def __init__(self,id:str=None, tag:str=\"div\", name:str=None,html:str=None, class_name:str=None, text:str=None, *args, **kwargs):\n+        \"\"\"\n+        Create a new HTMLElement.\n+        \n+        :param id: The id of the element\n+        :param tag: The tag of the element\n+        :param name: The name of the element, used to generate a class name if not provided\n+        :param html: The inner html of the element\n+        :param class_name: The class name of the element\n+        :param text: The text of the element\n+        \"\"\"\n+        self.tag = tag\n+        self.text = text\n+        self.id = id \n+        self.class_name = class_name or name\n+        self.html = html \n+        super().__init__(name=name,*args, **kwargs)\n+\n+    def to_json(self):\n+        \"\"\"\n+        Return a json representation of the element.\n+        \n+        This will return a dict with the following keys:\n+        \n+        - text: The text of the element\n+        - id: The id of the element\n+        - html: The inner html of the element\n+        - class_name: The class name of the element\n+        - tag: The tag of the element\n+        \n+        :return: A json representation of the element\n+        :rtype: dict\n+        \"\"\"\n+        result = super().to_json()\n+        result['text'] = self.text \n+        result['id'] = self.id \n+        result['html'] = self.html \n+        result['class_name'] = self.class_name\n+        result['tag'] = self.tag\n+        return result \n+\n+class FormElement(HTMLElement):\n+    pass\n+           \n+class FormInputElement(FormElement):\n+\n+    def __init__(self,type=\"text\",place_holder=None, *args, **kwargs):\n+        \"\"\"\n+        Initialize a FormInputElement with specified attributes.\n+\n+        :param type: The type of the input element (default is \"text\").\n+        :param place_holder: The placeholder text for the input element.\n+        :param args: Additional positional arguments.\n+        :param kwargs: Additional keyword arguments.\n+        \"\"\"\n+\n+        super().__init__(tag=\"input\", *args, **kwargs)\n+        self.place_holder = place_holder \n+        self.type = type\n+        \n+\n+    def to_json(self):\n+        \"\"\"\n+        Return a json representation of the element.\n+\n+        This will return a dict with the following keys:\n+\n+        - place_holder: The placeholder text for the input element\n+        - type: The type of the input element\n+\n+        :return: A json representation of the element\n+        :rtype: dict\n+        \"\"\"\n+        data = super().to_json()\n+        data[\"place_holder\"] = self.place_holder\n+        data[\"type\"] = self.type\n+        return data \n+    \n+class FormButtonElement(FormElement):\n+    def __init__(self, tag=\"button\", *args, **kwargs):\n+        \"\"\"\n+        Initialize a FormButtonElement with specified attributes.\n+\n+        :param tag: The tag of the button element (default is \"button\").\n+        :param args: Additional positional arguments.\n+        :param kwargs: Additional keyword arguments.\n+        \"\"\"\n+        super().__init__(tag=tag, *args, **kwargs)\n+\n+\n+class Form(model.BaseModel):\n+    \n+    @property\n+    def html_elements(self):\n+        \"\"\"\n+        Return a list of all :class:`HTMLElement` objects in the form.\n+\n+        This is a convenience property that filters the :attr:`fields` list to only\n+        include elements that are instances of :class:`HTMLElement`.\n+\n+        :return: A list of :class:`HTMLElement` objects\n+        :rtype: list\n+        \"\"\"\n+        json_elements = super().to_json()\n+        return [element for element in self.fields if isinstance(element,HTMLElement)]\n+    def set_user_data(self, data):\n+        \"\"\"\n+        Set user data for the form by updating the fields with the provided data.\n+\n+        This method extracts the 'fields' key from the provided data dictionary\n+        and passes it to the parent class's `set_user_data` method to update the\n+        form fields accordingly.\n+\n+        :param data: A dictionary containing the form data, expected to have a \n+                    'fields' key with the data to update the form fields.\n+        \"\"\"\n+\n+        return super().set_user_data(data.get('fields'))\n+\n+    def to_json(self, encode=False):\n+        \"\"\"\n+        Return a JSON representation of the form, including field values and metadata.\n+\n+        This method returns a dictionary with the following keys:\n+\n+        - ``fields``: A dictionary of field names to their current values.\n+        - ``is_valid``: A boolean indicating whether the form is valid.\n+        - ``errors``: A dictionary of field names to lists of error strings.\n+\n+        If the ``encode`` argument is ``True``, the dictionary will be JSON-encoded\n+        before being returned. Otherwise, the dictionary is returned directly.\n+\n+        :param encode: If ``True``, JSON-encode the returned dictionary.\n+        :type encode: bool\n+        :return: A JSON representation of the form.\n+        :rtype: dict\n+        \"\"\"\n+        elements = super().to_json()\n+        html_elements = {}\n+        for element in elements.keys():\n+            print(\"DDD!\",element,flush=True)\n+            field = getattr(self,element)\n+            if isinstance(field,HTMLElement):\n+                print(\"QQQQ!\",element,flush=True)\n+                try:\n+                    html_elements[element] = elements[element]\n+                except KeyError:\n+                    pass \n+\n+        return dict(fields=html_elements,is_valid=self.is_valid,errors=self.errors)\n+    @property\n+    def errors(self):\n+        \"\"\"\n+        Return a list of all error strings from all fields in the form.\n+\n+        The list will be empty if all fields are valid.\n+\n+        :return: A list of error strings.\n+        :rtype: list\n+        \"\"\"\n+        result = []\n+        for field in self.html_elements:\n+            result += field.errors \n+        return result \n+    @property\n+    def is_valid(self):\n+        return all(element.is_valid for element in self.html_elements)\ndiff --git a/src/snek/http.py b/src/snek/system/http.py\nsimilarity index 100%\nrename from src/snek/http.py\nrename to src/snek/system/http.py\ndiff --git a/src/snek/middleware.py b/src/snek/system/middleware.py\nsimilarity index 100%\nrename from src/snek/middleware.py\nrename to src/snek/system/middleware.py\ndiff --git a/src/snek/models.py b/src/snek/system/model.py\nsimilarity index 78%\nrename from src/snek/models.py\nrename to src/snek/system/model.py\nindex ec5d9ce..a699a9e 100644\n--- a/src/snek/models.py\n+++ b/src/snek/system/model.py\n@@ -23,7 +23,7 @@ def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**k\n             return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n \n class Validator:\n-\n+    _index = 0\n     @property\n     def value(self):\n         return self._value \n@@ -34,12 +34,14 @@ class Validator:\n \n     @property\n     def initial_value(self):\n-        return None\n+        return self.value\n \n     def custom_validation(self):\n         return True\n \n     def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+        self.index = Validator._index\n+        Validator._index += 1\n         self.required = required \n         self.min_num = min_num \n         self.max_num = max_num\n@@ -47,8 +49,10 @@ class Validator:\n         self.max_length = max_length \n         self.regex = regex \n         self._value = None \n-        self.value = value \n-        self.type = kind\n+        self.value = value\n+        print(\"xxxx\", value,flush=True) \n+        \n+        self.kind = kind\n         self.help_text = help_text \n         self.__dict__.update(kwargs)\n     @property \n@@ -61,7 +65,7 @@ class Validator:\n         if self.value is None:\n             return error_list \n \n-        if self.type == float or self.type == int:\n+        if self.kind == float or self.kind == int:\n             if self.min_num is not None and self.value < self.min_num:\n                 error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n             if self.max_num is not None and self.value > self.max_num:\n@@ -70,10 +74,11 @@ class Validator:\n             error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n         if self.max_length is not None and len(self.value) > self.max_length:\n             error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n+        print(self.regex, self.value,flush=True)\n         if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\".format(self.regex))\n-        if not self.type is None and type(self.value) != self.type:\n-            error_list.append(\"Invalid type. It is supposed to be {}.\".format(self.type))\n+        if not self.kind is None and type(self.value) != self.kind:\n+            error_list.append(\"Invalid kind. It is supposed to be {}.\".format(self.kind))\n         return error_list \n         \n     def validate(self):\n@@ -89,6 +94,8 @@ class Validator:\n         except ValueError:\n             return False\n \n+    \n+\n     def to_json(self):\n         return {\n             \"required\": self.required,\n@@ -98,19 +105,26 @@ class Validator:\n             \"max_length\": self.max_length,\n             \"regex\": self.regex,\n             \"value\": self.value,\n-            \"type\": self.type,\n+            \"kind\": str(self.kind),\n             \"help_text\": self.help_text,\n             \"errors\": self.errors,\n-            \"is_valid\": self.is_valid\n+            \"is_valid\": self.is_valid,\n+            \"index\":self.index\n         }\n \n class ModelField(Validator):\n+\n+    index = 1\n     def __init__(self,name=None,save=True, *args, **kwargs):\n         self.name = name \n-        \n         self.save = save\n         super().__init__(*args, **kwargs)\n \n+    def to_json(self):\n+        result = super().to_json()\n+        result['name'] = self.name\n+        return result \n+\n \n class CreatedField(ModelField):\n     \n@@ -146,9 +160,11 @@ class BaseModel:\n     updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n     deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n \n+   \n     def __init__(self, *args, **kwargs):\n         print(self.__dict__)\n         print(dir(self.__class__))\n+        self.fields = {}\n         for key in dir(self.__class__):\n             obj = getattr(self.__class__,key)\n \n@@ -156,6 +172,7 @@ class BaseModel:\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 print(\"JAAA\")\n                 self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+                self.fields[key] = self.__dict__[key]\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -169,6 +186,22 @@ class BaseModel:\n             return obj.value \n         return obj\n \n+    def set_user_data(self, data):\n+        for key, value in data.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue \n+            if value.get('name'):\n+                value = value.get('value')\n+            field.value = value\n+           \n+\n+    @property \n+    def is_valid(self):\n+        for field in self.fields.values():\n+            if not field.is_valid:\n+                return False\n+        return True\n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -180,7 +213,7 @@ class BaseModel:\n         if isinstance(obj,Validator):\n             obj.value = value\n         else:\n-            setattr(self,key,value)\n@@ -201,6 +234,7 @@ class BaseModel:\n             \"updated_at\": self.updated_at.value,\n             \"deleted_at\": self.deleted_at.value\n         })\n+        \n         for key,value in self.__dict__.items(): \n             if key == \"record\":\n                 continue\n@@ -225,39 +259,8 @@ class FormElement(ModelField):\n         self.place_holder = place_holder \n         super().__init__(*args, **kwargs)\n \n-\n     def to_json(self):\n         data = super().to_json()\n         data[\"name\"] = self.name \n         data[\"place_holder\"] = self.place_holder\n         return data \n-\n-\n-\n-\n-class TestModel(BaseModel):\n-\n-    first_name = FormElement(name=\"first_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"First name\")\n-    last_name = FormElement(name=\"last_name\",required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Last name\")\n-    email = FormElement(name=\"email\",required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")  \n-\n-class Form:\n-    username = FormElement(required=True,min_length=3,max_length=20,regex=r\"^[a-zA-Z0-9_]+$\",place_holder=\"Username\")\n-    email = FormElement(required=True,regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",place_holder=\"Email address\")\n-    def __init__(self, *args, **kwargs):\n-        self.place_holder = kwargs.pop(\"place_holder\",None) \n-\n-\n-if __name__ == \"__main__\":\n-    model = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"n9K9p@example.com\",password=\"Password123\")\n-    model2 = TestModel(first_name=\"John\",last_name=\"Doe\",email=\"ddd\",password=\"zzz\")\n-    model.first_name = \"AAA\"\n-    print(model.first_name)\n-    print(model.first_name.value)\n-    \n-    print(model.first_name)\n-    print(model.first_name.value)\n-    print(model.to_json(True))\n-    print(model2.to_json(True))\n-    print(model2.record)\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nnew file mode 100644\nindex 0000000..5b1bdf2\n--- /dev/null\n+++ b/src/snek/templates/base.html\n@@ -0,0 +1,31 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>{% block title %}{% endblock %}</title>\n+   <script src=\"/fancy-button.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n+    <script src=\"/html-frame.js\"></script>\n+    <script src=\"/generic-form.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n+  \n+</head>\n+<body>\n+    <header>\n+        {% block header %}\n+        {% endblock %}\n+\n+    </header>\n+    <main>\n+        <aside class=\"sidebar\">\n+        {% block sidebar %}\n+        \n+        {% endblock %}\n+    </aside>\n+    {% block main %}\n+    {% endblock %}\n+</main>\n+</body>\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/templates/base_chat.html b/src/snek/templates/base_chat.html\nnew file mode 100644\nindex 0000000..09c025b\n--- /dev/null\n+++ b/src/snek/templates/base_chat.html\n@@ -0,0 +1,31 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>{% block title %}{% endblock %}</title>\n+   <script src=\"/fancy-button.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/base.css\">\n+    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n+    <script src=\"/html-frame.js\"></script>\n+    <script src=\"/generic-form.js\"></script>\n+    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n+    <link rel=\"stylesheet\" href=\"/register__.css\">\n+</head>\n+<body>\n+    <header>\n+        {% block header %}\n+        {% endblock %}\n+\n+    </header>\n+    <main>\n+        <aside class=\"sidebar\">\n+            {% block sidebar %}\n+            \n+            {% endblock %}\n+        </aside>\n+    {% block main %}\n+    {% endblock %}\n+</main>\n+</body>\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nnew file mode 100644\nindex 0000000..1ee1f77\n--- /dev/null\n+++ b/src/snek/templates/index.html\n@@ -0,0 +1,20 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Snek chat by Molodetz</title>\n+    <link rel=\"stylesheet\" href=\"generic-form.css\">\n+  <link rel=\"stylesheet\" href=\"register__.css\">\n+    <script src=\"/fancy-button.js\"></script>\n+</head>\n+<body>\n+  <div class=\"registration-container\">\n+    <h1>Snek</h1>\n+    <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n+    <span style=\"padding:10px;\">Or</span>\n+    <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n+\n+  </div>\n+</body>\n+</html>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex d37f3fa..c09ec70 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,20 +1,5 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Register</title>\n-  <link rel=\"stylesheet\" href=\"register.css\">\n-</head>\n-<body>\n-  <div class=\"registration-container\">\n-    <h1>Login</h1>\n-    <form>\n-      <input type=\"text\" name=\"username\" placeholder=\"Username or password\" required>\n-      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n-      <button type=\"submit\">Create Account</button>\n-      <a href=\"/register\">Not having an account yet? Register here.</a>\n-    </form>\n-  </div>\n-</body>\n-</html>\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+  <generic-form url=\"/login-form\"></generic-form>\n+{% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex da41629..61da961 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,22 +1,5 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Register</title>\n-  <link rel=\"stylesheet\" href=\"register.css\">\n-</head>\n-<body>\n-  <div class=\"registration-container\">\n-    <h1>Register</h1>\n-    <form>\n-      <input type=\"text\" name=\"username\" placeholder=\"Username\" required>\n-      <input type=\"email\" name=\"email\" placeholder=\"Email Address\" required>\n-      <input type=\"password\" name=\"password\" placeholder=\"Password\" required>\n-      <input type=\"password\" name=\"confirm_password\" placeholder=\"Confirm Password\" required>\n-      <button type=\"submit\">Create Account</button>\n-    </form>\n-  </div>\n-</body>\n-</html>\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+  <generic-form url=\"/register-form\"></generic-form>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/test.html b/src/snek/templates/web.html\nsimilarity index 92%\nrename from src/snek/templates/test.html\nrename to src/snek/templates/web.html\nindex c23103a..0403e1b 100644\n--- a/src/snek/templates/test.html\n+++ b/src/snek/templates/web.html\n@@ -4,9 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Dark Themed Chat Application</title>\n-  <link rel=\"stylesheet\" href=\"styles.css\">\n-  <script src=\"/html_frame.js\"></script>\n-  <script src=\"/html_frame.css\"></script>\n+  <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n   <header>\n@@ -59,5 +57,4 @@\n   </main>\n   \n </body>\n-</html>\n-\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/view/base.py b/src/snek/view/base.py\nnew file mode 100644\nindex 0000000..d962ee5\n--- /dev/null\n+++ b/src/snek/view/base.py\n@@ -0,0 +1,31 @@\n+from aiohttp import web \n+\n+class BaseView(web.View):\n+    \n+    @property \n+    def app(self):\n+        return self.request.app\n+    \n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    def json_response(self, data):\n+        return web.json_response(data)\n+\n+    def render_template(self, template_name, context=None):\n+        return self.request.app.render_template(template_name, self.request,context)\n+    \n+class BaseFormView(BaseView):\n+\n+    form = None \n+\n+    async def get(self):\n+        form = self.form()\n+        return self.json_response(form.to_json())\n+       \n+    async def post(self):\n+        form = self.form()\n+        post = await self.request.json()\n+        form.set_user_data(post['form'])\n+        return self.json_response(form.to_json())  \n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nnew file mode 100644\nindex 0000000..a5d8b92\n--- /dev/null\n+++ b/src/snek/view/index.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView\n+\n+class IndexView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nnew file mode 100644\nindex 0000000..3a3beaf\n--- /dev/null\n+++ b/src/snek/view/login.py\n@@ -0,0 +1,13 @@\n+from snek.form.register import RegisterForm\n+from snek.view.base import BaseView \n+\n+class LoginView(BaseView):\n+\n+    async def get(self):\n+        \n+    async def post(self):\n+        form = RegisterForm()\n+        form.set_user_data(await self.request.post())\n+        print(form.is_valid())\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nnew file mode 100644\nindex 0000000..26527da\n--- /dev/null\n+++ b/src/snek/view/login_form.py\n@@ -0,0 +1,5 @@\n+from snek.view.base import BaseFormView\n+from snek.form.login import LoginForm\n+\n+class LoginFormView(BaseFormView):\n+    form = LoginForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nnew file mode 100644\nindex 0000000..095b7a3\n--- /dev/null\n+++ b/src/snek/view/register.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView \n+\n+class RegisterView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"register.html\")  \n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nnew file mode 100644\nindex 0000000..0ae7630\n--- /dev/null\n+++ b/src/snek/view/register_form.py\n@@ -0,0 +1,5 @@\n+from snek.form.register import RegisterForm\n+from snek.view.base import BaseFormView \n+\n+class RegisterFormView(BaseFormView):\n+    form = RegisterForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/view.py b/src/snek/view/view.py\nnew file mode 100644\nindex 0000000..ea642a3\n--- /dev/null\n+++ b/src/snek/view/view.py\n@@ -0,0 +1,6 @@\n+from snek.view.base import BaseView \n+\n+class WebView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"web.html\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor system and views for improved structure and maintainability", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527", "diff": "commit d20079f3ed8f261bcda0f5379f4c9e23ee941527\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:00:10 2025 +0100\n\n    Complete system.\n\ndiff --git a/.gitignore b/.gitignore\nindex 3747073..ece77be 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,5 +1,8 @@\n .vscode\n .history\n+.resources\n+.backup*\n+docs\n *.db*\n *.png\ndiff --git a/Dockerfile b/Dockerfile\nindex 47c0ece..9af8e87 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -1,5 +1,5 @@\n FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n-FROM python:3.10-alpine\n+FROM python:3.12.8-alpine3.21\n WORKDIR /code\n ENV FLASK_APP=app.py\n ENV FLASK_RUN_HOST=0.0.0.0\n@@ -37,5 +37,5 @@ RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 8081\n \n-python -m snek.app\n+CMD [\"python\",\"-m\",\"snek.app\"]\ndiff --git a/pyproject.toml b/pyproject.toml\nindex d98557e..cc36846 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -20,6 +20,8 @@ dependencies = [\n     \"beautifulsoup4\",\n     \"gunicorn\",\n     \"imgkit\",\n-    \"wkhtmltopdf\"\n+    \"wkhtmltopdf\",\n+    \"jinja-markdown2\",\n+    \"mistune\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0e2ed63..deac5d3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,14 +3,16 @@ import pathlib\n from aiohttp import web\n from app.app import Application as BaseApplication\n \n+from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n+from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n-from snek.view.view import WebView\n+from snek.view.web import WebView\n \n \n class Application(BaseApplication):\n@@ -24,6 +26,7 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n+        self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n \n     def setup_router(self):\n@@ -34,12 +37,14 @@ class Application(BaseApplication):\n             name=\"static\",\n             show_index=True,\n         )\n-        self.router.add_view(\"/web\", WebView)\n-        self.router.add_view(\"/login\", LoginView)\n-        self.router.add_view(\"/login-form\", LoginFormView)\n-        self.router.add_view(\"/register\", RegisterView)\n+        self.router.add_view(\"/about.html\", AboutHTMLView)\n+        self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/web.html\", WebView)\n+        self.router.add_view(\"/login.html\", LoginView)\n+        self.router.add_view(\"/login-form.json\", LoginFormView)\n+        self.router.add_view(\"/register.html\", RegisterView)\n         \n-        self.router.add_view(\"/register-form\", RegisterFormView)\n+        self.router.add_view(\"/register-form.json\", RegisterFormView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 60399fb..7dff3e4 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -15,7 +15,7 @@ class RegisterForm(Form):\n     )\n     email = FormInputElement(\n         name=\"email\",\n-        required=True,\n+        required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n         place_holder=\"Email address\",\n         type=\"email\"\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nnew file mode 100644\nindex 0000000..dc9e047\n--- /dev/null\n+++ b/src/snek/mapper/__init__.py\n@@ -0,0 +1,12 @@\n+import functools \n+from snek.mapper.user import UserMapper\n+\n+@functools.cache \n+def get_mappers(app=None):\n+    return dict(\n+        user=UserMapper(app=app)\n+\n+    )\n+\n+def get_mapper(name, app=None):\n+    return get_mappers(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nnew file mode 100644\nindex 0000000..5b8671e\n--- /dev/null\n+++ b/src/snek/mapper/user.py\n@@ -0,0 +1,6 @@\n+from snek.system.mapper import BaseMapper\n+from snek.model.user import UserModel\n+\n+class UserMapper(BaseMapper):\n+    table_name = \"user\"\n+    model: UserModel \n\\ No newline at end of file\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex e69de29..52af21a 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -0,0 +1,12 @@\n+from snek.model.user import UserModel \n+import functools \n+\n+@functools.cache\n+def get_models():\n+    return dict(\n+        user=UserModel\n+\n+    )\n+\n+def get_model(name):\n+    return get_models()[name]\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 44553f8..254b6c9 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,6 +1,6 @@\n from snek.system.model import BaseModel,ModelField\n \n-class User(BaseModel):\n+class UserModel(BaseModel):\n     \n     username = ModelField(\n         name=\"username\", \n@@ -11,7 +11,7 @@ class User(BaseModel):\n     )\n     email = ModelField(\n         name=\"email\",\n-        required=True,\n+        required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n     )\n     password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nnew file mode 100644\nindex 0000000..4038f70\n--- /dev/null\n+++ b/src/snek/service/__init__.py\n@@ -0,0 +1,12 @@\n+from snek.service.user import UserService \n+import functools \n+\n+@functools.cache\n+def get_services(app):\n+\n+    return dict(\n+        user = UserService(app=app)\n+\n+    )\n+def get_service(name, app=None):\n+    return get_services(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nnew file mode 100644\nindex 0000000..cde4b8c\n--- /dev/null\n+++ b/src/snek/service/user.py\n@@ -0,0 +1,16 @@\n+from snek.system.service import BaseService \n+from snek.system import security \n+\n+class UserService:\n+    mapper_name = \"user\"\n+\n+    async def create_user(self, username, password):\n+        if await self.exists(username=username):\n+            raise Exception(\"User already exists.\")\n+        model = await self.new()\n+        model.username = username\n+        model.password = await security.hash(password)\n+        if await self.save(model):\n+            return model \n+        raise Exception(f\"Failed to create user: {model.errors}.\")\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 11fea47..58a67e2 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -224,7 +224,11 @@ class GenericForm extends HTMLElement {\n \n       }\n       @media (max-width: 500px) {\n+        width:100%;\n+        height:100%;\n           form {\n+              height:100%;\n+              width: 100%;\n               width: 80%;\n           }\n       }`\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 22581ce..0d5d4c9 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -26,7 +26,16 @@ class HTMLFrame extends HTMLElement {\n           throw new Error(`Error: ${response.status} ${response.statusText}`);\n         }\n         const html = await response.text();\n-        this.container.innerHTML = html;\n+        if(url.endsWith(\".md\")){\n+          const parent = this\n+          const markdownElement = document.createElement('div')\n+          markdownElement.innerHTML = html\n+          document.body.appendChild(markdownElement)\n+          \n+        }else{\n+          this.container.innerHTML = html;\n+        }\n         \n     } catch (error) {\n         this.container.textContent = `Error: ${error.message}`;\ndiff --git a/src/snek/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nnew file mode 100644\nindex 0000000..e2b7a77\n--- /dev/null\n+++ b/src/snek/static/markdown-frame.js\n@@ -0,0 +1,39 @@\n+\n+\n+\n+class HTMLFrame extends HTMLElement {\n+    constructor() {\n+      super();\n+      this.attachShadow({ mode: 'open' });\n+      this.container = document.createElement('div');\n+      this.shadowRoot.appendChild(this.container);\n+    }\n+\n+    connectedCallback() {\n+        this.container.classList.add(\"html_frame\")\n+      const url = this.getAttribute('url');\n+      if (url) {\n+        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!url.startsWith(\"/\"))\n+            fullUrl.searchParams.set('url', url)   \n+        this.loadAndRender(fullUrl.toString());\n+      } else {\n+        this.container.textContent = \"No source URL!\";\n+      }\n+    }\n+\n+    async loadAndRender(url) {\n+      try {\n+        const response = await fetch(url);\n+        if (!response.ok) {\n+          throw new Error(`Error: ${response.status} ${response.statusText}`);\n+        }\n+        const html = await response.text();\n+        this.container.innerHTML = html;\n+        \n+    } catch (error) {\n+        this.container.textContent = `Error: ${error.message}`;\n+      }\n+    }\n+  }\n+  customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nnew file mode 100644\nindex 0000000..990fcf9\n--- /dev/null\n+++ b/src/snek/static/style.css\n@@ -0,0 +1,20 @@\n+h1 {\n+    font-size: 2em;\n+    margin-bottom: 20px;\n+}\n+\n+h2 {\n+    font-size: 1.4em;\n+    margin-bottom: 20px;\n+}\n+body {\n+\n+}\n+div {\n+    text-align: left;\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/system/api.py b/src/snek/system/api.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 68f7c0f..f9ebebb 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,171 +1,96 @@\n-from snek.system import model \n+\n+\n+\n+\n+from snek.system import model\n \n class HTMLElement(model.ModelField):\n-    def __init__(self,id:str=None, tag:str=\"div\", name:str=None,html:str=None, class_name:str=None, text:str=None, *args, **kwargs):\n-        \"\"\"\n-        Create a new HTMLElement.\n-        \n-        :param id: The id of the element\n-        :param tag: The tag of the element\n-        :param name: The name of the element, used to generate a class name if not provided\n-        :param html: The inner html of the element\n-        :param class_name: The class name of the element\n-        :param text: The text of the element\n-        \"\"\"\n+    def __init__(self, id=None, tag=\"div\", name=None, html=None, class_name=None, text=None, *args, **kwargs):\n         self.tag = tag\n         self.text = text\n-        self.id = id \n+        self.id = id\n         self.class_name = class_name or name\n-        self.html = html \n-        super().__init__(name=name,*args, **kwargs)\n+        self.html = html\n+        super().__init__(name=name, *args, **kwargs)\n \n     def to_json(self):\n-        \"\"\"\n-        Return a json representation of the element.\n-        \n-        This will return a dict with the following keys:\n-        \n-        - text: The text of the element\n-        - id: The id of the element\n-        - html: The inner html of the element\n-        - class_name: The class name of the element\n-        - tag: The tag of the element\n-        \n-        :return: A json representation of the element\n-        :rtype: dict\n-        \"\"\"\n         result = super().to_json()\n-        result['text'] = self.text \n-        result['id'] = self.id \n-        result['html'] = self.html \n+        result['text'] = self.text\n+        result['id'] = self.id\n+        result['html'] = self.html\n         result['class_name'] = self.class_name\n         result['tag'] = self.tag\n-        return result \n+        return result\n \n class FormElement(HTMLElement):\n     pass\n-           \n-class FormInputElement(FormElement):\n-\n-    def __init__(self,type=\"text\",place_holder=None, *args, **kwargs):\n-        \"\"\"\n-        Initialize a FormInputElement with specified attributes.\n-\n-        :param type: The type of the input element (default is \"text\").\n-        :param place_holder: The placeholder text for the input element.\n-        :param args: Additional positional arguments.\n-        :param kwargs: Additional keyword arguments.\n-        \"\"\"\n \n+class FormInputElement(FormElement):\n+    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n         super().__init__(tag=\"input\", *args, **kwargs)\n-        self.place_holder = place_holder \n+        self.place_holder = place_holder\n         self.type = type\n-        \n \n     def to_json(self):\n-        \"\"\"\n-        Return a json representation of the element.\n-\n-        This will return a dict with the following keys:\n-\n-        - place_holder: The placeholder text for the input element\n-        - type: The type of the input element\n-\n-        :return: A json representation of the element\n-        :rtype: dict\n-        \"\"\"\n         data = super().to_json()\n         data[\"place_holder\"] = self.place_holder\n         data[\"type\"] = self.type\n-        return data \n-    \n+        return data\n+\n class FormButtonElement(FormElement):\n     def __init__(self, tag=\"button\", *args, **kwargs):\n-        \"\"\"\n-        Initialize a FormButtonElement with specified attributes.\n-\n-        :param tag: The tag of the button element (default is \"button\").\n-        :param args: Additional positional arguments.\n-        :param kwargs: Additional keyword arguments.\n-        \"\"\"\n         super().__init__(tag=tag, *args, **kwargs)\n \n-\n class Form(model.BaseModel):\n-    \n     @property\n     def html_elements(self):\n-        \"\"\"\n-        Return a list of all :class:`HTMLElement` objects in the form.\n-\n-        This is a convenience property that filters the :attr:`fields` list to only\n-        include elements that are instances of :class:`HTMLElement`.\n-\n-        :return: A list of :class:`HTMLElement` objects\n-        :rtype: list\n-        \"\"\"\n         json_elements = super().to_json()\n-        return [element for element in self.fields if isinstance(element,HTMLElement)]\n-    def set_user_data(self, data):\n-        \"\"\"\n-        Set user data for the form by updating the fields with the provided data.\n-\n-        This method extracts the 'fields' key from the provided data dictionary\n-        and passes it to the parent class's `set_user_data` method to update the\n-        form fields accordingly.\n-\n-        :param data: A dictionary containing the form data, expected to have a \n-                    'fields' key with the data to update the form fields.\n-        \"\"\"\n+        return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n+    def set_user_data(self, data):\n         return super().set_user_data(data.get('fields'))\n \n     def to_json(self, encode=False):\n-        \"\"\"\n-        Return a JSON representation of the form, including field values and metadata.\n-\n-        This method returns a dictionary with the following keys:\n-\n-        - ``fields``: A dictionary of field names to their current values.\n-        - ``is_valid``: A boolean indicating whether the form is valid.\n-        - ``errors``: A dictionary of field names to lists of error strings.\n-\n-        If the ``encode`` argument is ``True``, the dictionary will be JSON-encoded\n-        before being returned. Otherwise, the dictionary is returned directly.\n-\n-        :param encode: If ``True``, JSON-encode the returned dictionary.\n-        :type encode: bool\n-        :return: A JSON representation of the form.\n-        :rtype: dict\n-        \"\"\"\n         elements = super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n-            print(\"DDD!\",element,flush=True)\n-            field = getattr(self,element)\n-            if isinstance(field,HTMLElement):\n-                print(\"QQQQ!\",element,flush=True)\n+            field = getattr(self, element)\n+            if isinstance(field, HTMLElement):\n                 try:\n                     html_elements[element] = elements[element]\n                 except KeyError:\n-                    pass \n+                    pass\n+        return dict(fields=html_elements, is_valid=self.is_valid, errors=self.errors)\n \n-        return dict(fields=html_elements,is_valid=self.is_valid,errors=self.errors)\n     @property\n     def errors(self):\n-        \"\"\"\n-        Return a list of all error strings from all fields in the form.\n-\n-        The list will be empty if all fields are valid.\n-\n-        :return: A list of error strings.\n-        :rtype: list\n-        \"\"\"\n         result = []\n         for field in self.html_elements:\n-            result += field.errors \n-        return result \n+            result += field.errors\n+        return result\n+\n     @property\n     def is_valid(self):\n-        return all(element.is_valid for element in self.html_elements)\n+        return all(element.is_valid for element in self.html_elements)\n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex 0b16bee..b5e8b4f 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,77 +1,99 @@\n-from aiohttp import web \n-import aiohttp \n+\n+\n+\n+\n+\n+from aiohttp import web\n+import aiohttp\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n from urllib.parse import urljoin\n-import pathlib \n-import uuid \n-import imgkit \n+import pathlib\n+import uuid\n+import imgkit\n import asyncio\n import zlib\n-import io \n+import io\n \n async def crc32(data):\n     try:\n         data = data.encode()\n     except:\n-        pass \n-    result = \"crc32\" + str(zlib.crc32(data))\n-    return result \n+        pass\n+    return \"crc32\" + str(zlib.crc32(data))\n \n-async def get_file(name,suffix=\".cache\"):\n+async def get_file(name, suffix=\".cache\"):\n     name = await crc32(name)\n     path = pathlib.Path(\".\").joinpath(\"cache\")\n     if not path.exists():\n-        path.mkdir(parents=True,exist_ok=True)\n-    path = path.joinpath(name + suffix)\n-    return path\n-\n-\n+        path.mkdir(parents=True, exist_ok=True)\n+    return path.joinpath(name + suffix)\n \n async def public_touch(name=None):\n-    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4())+name)\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n     path.open(\"wb\").close()\n-    return path \n+    return path\n \n async def create_site_photo(url):\n     loop = asyncio.get_event_loop()\n     if not url.startswith(\"https\"):\n-    output_path = await get_file(\"site-screenshot-\" + url,\".png\")\n+    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n     \n     if output_path.exists():\n         return output_path\n     output_path.touch()\n+\n     def make_photo():\n         imgkit.from_url(url, output_path.absolute())\n-        return output_path \n+        return output_path\n \n-    return await loop.run_in_executor(None,make_photo)\n+    return await loop.run_in_executor(None, make_photo)\n \n async def repair_links(base_url, html_content):\n     soup = BeautifulSoup(html_content, \"html.parser\")\n     for tag in soup.find_all(['a', 'img', 'link']):\n+        if tag.has_attr('href') and not tag['href'].startswith(\"http\"):\n             tag['href'] = urljoin(base_url, tag['href'])\n+        if tag.has_attr('src') and not tag['src'].startswith(\"http\"):\n             tag['src'] = urljoin(base_url, tag['src'])\n-            print(\"Fixed: \",tag['src'])\n     return soup.prettify()\n \n async def is_html_content(content: bytes):\n     try:\n         content = content.decode(errors='ignore')\n     except:\n-        pass \n-    marks = ['<html','<img','<p','<span','<div']\n+        pass\n+    marks = ['<html', '<img', '<p', '<span', '<div']\n     try:\n         content = content.lower()\n         for mark in marks:\n             if mark in content:\n-                return True \n+                return True\n     except Exception as ex:\n         print(ex)\n-    return False \n+    return False\n \n @time_cache_async(120)\n async def get(url):\n@@ -79,5 +101,5 @@ async def get(url):\n         response = await session.get(url)\n         content = await response.text()\n         if await is_html_content(content):\n-            content = (await repair_links(url,content)).encode()\n+            content = (await repair_links(url, content)).encode()\n         return content\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nnew file mode 100644\nindex 0000000..f4beb2e\n--- /dev/null\n+++ b/src/snek/system/mapper.py\n@@ -0,0 +1,64 @@\n+\n+DEFAULT_LIMIT = 30\n+from snek.system.model import BaseModel\n+from snek.app import Application \n+import types\n+\n+class Mapper:\n+\n+    model_class:BaseModel = None \n+    default_limit:int = DEFAULT_LIMIT\n+    table_name:str = None \n+\n+    def __init__(self, app:Application, table_name:str, model_class:BaseModel):\n+        self.app = app \n+        \n+        if not self.model_class:\n+            raise ValueError(\"Mapper configuration error: model_class is not set.\")\n+        self.model_class = model_class \n+        \n+        self.table_name = table_name\n+        if not self.table_name:\n+            raise ValueError(\"Mapper configuration error: table_name is not set.\")\n+        self.default_limit = self.__class__.default_limit \n+    \n+    @property\n+    def db(self): \n+        return self.app.db\n+\n+    async def new(self):\n+        return self.model_class(mapper=self)\n+\n+    @property\n+    def table(self):\n+        return self.db[self.table_name]\n+\n+    async def get(self, uid:str=None, **kwargs) -> types.Optional[BaseModel]\n+        if uid:\n+            kwargs['uid'] = uid \n+        model = self.new()\n+        record = self.table.find_one(**kwargs)\n+        return self.model_class.from_record(mapper=self,record=record)\n+\n+    async def exists(self, **kwargs):\n+        return self.table.exists(**kwargs)\n+\n+    async def count(self, **kwargs) -> int:\n+        return self.table.count(**kwargs)\n+\n+    async def save(self, model:BaseModel) -> bool:\n+        record = model.record\n+        if not record.get('uid'):\n+            raise Exception(f\"Attempt to save without uid: {record}.\")\n+        return self.table.upsert(record,['uid'])\n+\n+    async def find(self, **kwargs) -> types.List[BaseModel]:\n+        if not kwargs.get(\"_limit\"):\n+            kwargs[\"_limit\"] = self.default_limit\n+        for record in self.table.find(**kwargs):\n+            yield self.model_class.from_record(mapper=self,record=record)\n+    \n+    async def delete(self, kwargs=None)-> int: \n+        if not kwargs or not isinstance(kwargs, dict):\n+            raise Exception(\"Can't execute delete with no filter.\")\n+        return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nnew file mode 100644\nindex 0000000..bded949\n--- /dev/null\n+++ b/src/snek/system/markdown.py\n@@ -0,0 +1,43 @@\n+\n+\n+from mistune import escape\n+from mistune import Markdown\n+from mistune import HTMLRenderer\n+from pygments import highlight\n+from pygments.lexers import get_lexer_by_name\n+from pygments.formatters import html\n+from pygments.styles import get_style_by_name\n+\n+\n+class MarkdownRenderer(HTMLRenderer):\n+    def __init__(self, app, template):\n+         self.template = template\n+         \n+         self.app = app\n+         self.env = self.app.jinja2_env\n+         formatter = html.HtmlFormatter()\n+         self.env.globals['highlight_styles'] = formatter.get_style_defs()\n+    def _escape(self,str):\n+    def block_code(self, code, lang=None,info=None):\n+        if not lang:\n+            lang = info\n+        if not lang:\n+            return f\"<div>{code}</div>\"\n+        lexer = get_lexer_by_name(lang, stripall=True)\n+        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n+        print(code, lang,info, flush=True)\n+        return highlight(code, lexer, formatter)\n+    def render(self):\n+        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n+        renderer = MarkdownRenderer(self.app,self.template)\n+        markdown = Markdown(renderer=renderer)\n+        return markdown(markdown_string)\n+\n+\n+async def render_markdown(app, markdown_string):\n+     renderer = MarkdownRenderer(app,None)\n+     markdown = Markdown(renderer=renderer)\n+     return markdown(markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 6b801ab..7fe457f 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,4 +1,12 @@\n-from aiohttp import web \n+\n+\n+\n+\n+from aiohttp import web\n \n @web.middleware\n async def no_cors_middleware(request, handler):\n@@ -7,16 +15,15 @@ async def no_cors_middleware(request, handler):\n     return response\n \n @web.middleware\n-async def cors_allow_middleware(request ,handler):\n+async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    return response \n+    return response\n \n @web.middleware\n async def cors_middleware(request, handler):\n     if request.method == \"OPTIONS\":\n         response = web.Response()\n         response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n@@ -24,7 +31,6 @@ async def cors_middleware(request, handler):\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n         return response\n \n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex a699a9e..eb490b3 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,36 +1,67 @@\n+\n+\n+\n+\n+\n import re\n import uuid\n-import json \n-from datetime import datetime , timezone \n+import json\n+from datetime import datetime, timezone\n from collections import OrderedDict\n-import copy \n+import copy\n \n TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n \n+\n def now():\n     return str(datetime.now(timezone.utc))\n \n+\n def add_attrs(**kwargs):\n     def decorator(func):\n         for key, value in kwargs.items():\n             setattr(func, key, value)\n-\n         return func\n     return decorator\n \n-def validate_attrs(required=False,min_length=None,max_length=None,regex=None,**kwargs):\n-        def decorator(func):\n-            return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**kwargs)(func)\n+\n+def validate_attrs(required=False, min_length=None, max_length=None, regex=None, **kwargs):\n+    def decorator(func):\n+        return add_attrs(required=required, min_length=min_length, max_length=max_length, regex=regex, **kwargs)(func)\n+\n \n class Validator:\n     _index = 0\n+\n     @property\n     def value(self):\n-        return self._value \n+        return self._value\n \n-    @value.setter \n-    def value(self,val):\n-        self._value = json.loads(json.dumps(val,default=str))\n+    @value.setter\n+    def value(self, val):\n+        self._value = json.loads(json.dumps(val, default=str))\n \n     @property\n     def initial_value(self):\n@@ -39,48 +70,49 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self,required=False,min_num=None,max_num=None,min_length=None,max_length=None,regex=None,value=None,kind=None,help_text=None,**kwargs):\n+    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, **kwargs):\n         self.index = Validator._index\n         Validator._index += 1\n-        self.required = required \n-        self.min_num = min_num \n+        self.required = required\n+        self.min_num = min_num\n         self.max_num = max_num\n-        self.min_length = min_length \n-        self.max_length = max_length \n-        self.regex = regex \n-        self._value = None \n+        self.min_length = min_length\n+        self.max_length = max_length\n+        self.regex = regex\n+        self._value = None\n         self.value = value\n-        print(\"xxxx\", value,flush=True) \n-        \n+        print(\"xxxx\", value, flush=True)\n+\n         self.kind = kind\n-        self.help_text = help_text \n+        self.help_text = help_text\n         self.__dict__.update(kwargs)\n-    @property \n+\n+    @property\n     def errors(self):\n         error_list = []\n         if self.value is None and self.required:\n             error_list.append(\"Field is required.\")\n-            return error_list \n-        \n+            return error_list\n+\n         if self.value is None:\n-            return error_list \n+            return error_list\n \n-        if self.kind == float or self.kind == int:\n+        if self.kind in [int, float]:\n             if self.min_num is not None and self.value < self.min_num:\n-                error_list.append(\"Field should be minimal {}.\".format(self.min_num))\n+                error_list.append(f\"Field should be minimal {self.min_num}.\")\n             if self.max_num is not None and self.value > self.max_num:\n-                error_list.append(\"Field should be maximal {}.\".format(self.max_num))\n+                error_list.append(f\"Field should be maximal {self.max_num}.\")\n         if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(\"Field should be minimal {} characters long.\".format(self.min_length))\n+            error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n         if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(\"Field should be maximal {} characters long.\".format(self.max_length))\n-        print(self.regex, self.value,flush=True)\n-        if not self.regex is None and not self.value is None and not re.match(self.regex, self.value):\n-            error_list.append(\"Invalid value.\".format(self.regex))\n-        if not self.kind is None and type(self.value) != self.kind:\n-            error_list.append(\"Invalid kind. It is supposed to be {}.\".format(self.kind))\n-        return error_list \n-        \n+            error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n+        print(self.regex, self.value, flush=True)\n+        if self.regex and self.value and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\")\n+        if self.kind and not isinstance(self.value, self.kind):\n+            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n+        return error_list\n+\n     def validate(self):\n         if self.errors:\n             raise ValueError(\"\\n\", self.errors)\n@@ -94,8 +126,6 @@ class Validator:\n         except ValueError:\n             return False\n \n-    \n-\n     def to_json(self):\n         return {\n             \"required\": self.required,\n@@ -109,25 +139,27 @@ class Validator:\n             \"help_text\": self.help_text,\n             \"errors\": self.errors,\n             \"is_valid\": self.is_valid,\n-            \"index\":self.index\n+            \"index\": self.index\n         }\n \n+\n class ModelField(Validator):\n \n     index = 1\n-    def __init__(self,name=None,save=True, *args, **kwargs):\n-        self.name = name \n+\n+    def __init__(self, name=None, save=True, *args, **kwargs):\n+        self.name = name\n         self.save = save\n         super().__init__(*args, **kwargs)\n \n     def to_json(self):\n         result = super().to_json()\n         result['name'] = self.name\n-        return result \n+        return result\n \n \n class CreatedField(ModelField):\n-    \n+\n     @property\n     def initial_value(self):\n         return now()\n@@ -136,67 +168,99 @@ class CreatedField(ModelField):\n         if not self.value:\n             self.value = now()\n \n+\n class UpdatedField(ModelField):\n \n     def update(self):\n         self.value = now()\n \n+\n class DeletedField(ModelField):\n \n     def update(self):\n         self.value = now()\n \n+\n class UUIDField(ModelField):\n-    \n-    @property \n+\n+    @property\n     def initial_value(self):\n         return str(uuid.uuid4())\n \n \n class BaseModel:\n+\n+    uid = UUIDField(name=\"uid\", required=True)\n+    created_at = CreatedField(name=\"created_at\", required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n+    updated_at = UpdatedField(name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\")\n+    deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+\n+    @classmethod \n+    def from_record(cls, record, mapper):\n+        model = cls.__new__()\n+        model.mapper = mapper \n+        model.record = record\n+        return model\n+\n+    @property \n+    def mapper(self):\n+        return self._mapper \n+\n+    @mapper.setter  \n+    def mapper(self, value):\n+        self._mapper = value  \n+\n+    @property \n+    def record(self):\n+        return {field.name: field.value for field in self.fields}\n     \n-    uid = UUIDField(name=\"uid\",required=True)\n-    created_at = CreatedField(name=\"created_at\",required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n-    updated_at = UpdatedField(name=\"updated_at\",regex=TIMESTAMP_REGEX,place_holder=\"Updated at\")\n-    deleted_at = DeletedField(name=\"deleted_at\",regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n+    @record.setter \n+    def record(self, value):\n+        for key, value in self._record.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            field.value = value\n+        return self\n \n-   \n     def __init__(self, *args, **kwargs):\n         print(self.__dict__)\n         print(dir(self.__class__))\n+        self._mapper = None\n         self.fields = {}\n         for key in dir(self.__class__):\n-            obj = getattr(self.__class__,key)\n+            obj = getattr(self.__class__, key)\n \n-            if isinstance(obj,Validator):\n+            if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 print(\"JAAA\")\n-                self.__dict__[key].value = kwargs.pop(key,self.__dict__[key].initial_value)\n+                self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n-            obj.value = value \n+        if isinstance(obj, Validator):\n+            obj.value = value\n \n     def __getattr__(self, key):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n+        if isinstance(obj, Validator):\n             print(\"HPAPP\")\n-            return obj.value \n+            return obj.value\n         return obj\n \n     def set_user_data(self, data):\n         for key, value in data.items():\n             field = self.fields.get(key)\n             if not field:\n-                continue \n+                continue\n             if value.get('name'):\n                 value = value.get('value')\n             field.value = value\n-           \n \n-    @property \n+    \n+\n+    @property\n     def is_valid(self):\n         for field in self.fields.values():\n             if not field.is_valid:\n@@ -205,46 +269,44 @@ class BaseModel:\n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n-        if isinstance(obj,Validator):\n-            return obj.value \n+        if isinstance(obj, Validator):\n+            return obj.value\n \n     def __setattr__(self, key, value):\n-        obj = getattr(self,key)\n-        if isinstance(obj,Validator):\n+        obj = getattr(self, key)\n+        if isinstance(obj, Validator):\n             obj.value = value\n         else:\n-    @property \n+            self.__dict__[key] = value\n+\n+    @property\n     def record(self):\n         obj = self.to_json()\n         record = {}\n-        for key,value in obj.items():\n-            if getattr(self,key).save:\n+        for key, value in obj.items():\n+            if getattr(self, key).save:\n                 record[key] = value.get('value')\n         return record\n \n-    def to_json(self,encode=False):\n+    def to_json(self, encode=False):\n         model_data = OrderedDict({\n             \"uid\": self.uid.value,\n             \"created_at\": self.created_at.value,\n             \"updated_at\": self.updated_at.value,\n             \"deleted_at\": self.deleted_at.value\n         })\n-        \n-        for key,value in self.__dict__.items(): \n+\n+        for key, value in self.__dict__.items():\n             if key == \"record\":\n                 continue\n             value = self.__dict__[key]\n-            if hasattr(value,\"value\"):\n+            if hasattr(value, \"value\"):\n                 model_data[key] = value.to_json()\n         if encode:\n-            return json.dumps(model_data,indent=2)\n+            return json.dumps(model_data, indent=2)\n         return model_data\n \n+\n class FormElement(ModelField):\n \n     def __init__(self, place_holder=None, *args, **kwargs):\n@@ -252,15 +314,14 @@ class FormElement(ModelField):\n         self.place_holder = place_holder\n \n \n-\n class FormElement(ModelField):\n \n-    def __init__(self,place_holder=None, *args, **kwargs): \n-        self.place_holder = place_holder \n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        self.place_holder = place_holder\n         super().__init__(*args, **kwargs)\n \n     def to_json(self):\n         data = super().to_json()\n-        data[\"name\"] = self.name \n+        data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n-        return data \n+        return data\n\\ No newline at end of file\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nnew file mode 100644\nindex 0000000..b319f54\n--- /dev/null\n+++ b/src/snek/system/security.py\n@@ -0,0 +1,20 @@\n+import hashlib \n+\n+DEFAULT_SALT = b\"snekker-de-snek-\"\n+\n+async def hash(data,salt=DEFAULT_SALT):\n+    try:\n+        data = data.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass \n+    try:\n+        salt = salt.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    salted = salt + data\n+\n+    obj = hashlib.sha256(salted)\n+    return obj.hexdigest()\n+\n+async def verify(string:str, hashed:str):\n+    return await hash(string) == hashed \ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nnew file mode 100644\nindex 0000000..5a8b553\n--- /dev/null\n+++ b/src/snek/system/service.py\n@@ -0,0 +1,40 @@\n+\n+\n+\n+from snek.mapper import get_mapper\n+from snek.system.mapper import BaseMapper \n+from snek.model.user import UserModel\n+\n+class BaseService:\n+\n+    mapper_name:BaseMapper = None\n+\n+    def __init__(self, app):\n+        self.app = app \n+        if self.mapper_name:\n+            self.mapper = get_mapper(self.mapper_name, app=self.app)\n+        else:\n+            self.mapper = None \n+\n+    async def exists(self, **kwargs):\n+        return self.mapper.exists(**kwargs)\n+    \n+    async def count(self, **kwargs):\n+        return self.mapper.count(**kwargs)\n+\n+    async def new(self, **kwargs):\n+        return await self.mapper.new()\n+\n+    async def get(self, **kwargs):\n+        return await self.mapper.get(**kwargs)\n+    \n+    async def save(self, model:UserModel):\n+        if model.is_valid:\n+            return self.mapper.save(model) and True \n+        return False \n+    \n+    async def find(self, **kwargs):\n+        return await self.mapper.find(**kwargs)\n+    \n+    async def delete(self, **kwargs):\n+        return await self.mapper.delete(**kwargs)\n\\ No newline at end of file\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nnew file mode 100644\nindex 0000000..458aa20\n--- /dev/null\n+++ b/src/snek/system/view.py\n@@ -0,0 +1,38 @@\n+from aiohttp import web\n+\n+from snek.system.markdown import render_markdown \n+\n+class BaseView(web.View):\n+    \n+    @property \n+    def app(self):\n+        return self.request.app\n+    \n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    async def json_response(self, data):\n+        return web.json_response(data)\n+\n+    async def render_template(self, template_name, context=None):\n+        if template_name.endswith(\".md\"):\n+            response = await self.request.app.render_template(template_name,self.request,context)\n+            body = await render_markdown(self.app, response.body.decode())\n+            return web.Response(body=body,content_type=\"text/html\")\n+        return await self.request.app.render_template(template_name, self.request,context)\n+    \n+class BaseFormView(BaseView):\n+\n+    form = None \n+\n+    async def get(self):\n+        form = self.form()\n+        return await self.json_response(form.to_json())\n+       \n+    async def post(self):\n+        form = self.form()\n+        post = await self.request.json()\n+        form.set_user_data(post['form'])\n+        return await self.json_response(form.to_json())  \n+\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nnew file mode 100644\nindex 0000000..0f1b8a9\n--- /dev/null\n+++ b/src/snek/templates/about.html\n@@ -0,0 +1,7 @@\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+\n+<html-frame url=\"/about.md\"></html-frame>\n+<fancy-button text=\"Back\" url=\"/web.html\"></fancy-button>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nnew file mode 100644\nindex 0000000..134fbc9\n--- /dev/null\n+++ b/src/snek/templates/about.md\n@@ -0,0 +1,15 @@\n+\n+A snek is a danger noodle.\n+\n+I made several design choices: \n+- Implemented **the worst 3rd party markdown to html renderer ever**. See this nice *bullet list*.\n+ - Only password requirement is  thats it requires six characters. Users are responsibly for their own security. Snek is not so arrogant to determine if a password is strong enough. It's up to what user prefers. Snek does not have a forgot-my-password service tho.\n+ - Email is not required for registration. Email is (maybe) used in future for resetting password.\n+ - Homebrew made ORM framework based on dataset.\n+ - Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure.\n+ - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n+ - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 5b1bdf2..9f57467 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,9 +4,8 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\n-    <link rel=\"stylesheet\" href=\"/style.css\">\n-    <link rel=\"stylesheet\" href=\"/generic-form.css\">\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 1ee1f77..c2200fb 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -14,7 +14,8 @@\n     <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">Or</span>\n     <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n-\n+    <a href=\"/about.html\">Design choices</a>\n+    <a href=\"/web.html\">See web Application so far</a>\n   </div>\n </body>\n </html>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex c09ec70..c70d429 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/login-form\"></generic-form>\n+  <generic-form url=\"/login-form.json\"></generic-form>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 61da961..f0a82e0 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/register-form\"></generic-form>\n+  <generic-form url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nnew file mode 100644\nindex 0000000..593d5a9\n--- /dev/null\n+++ b/src/snek/view/about.py\n@@ -0,0 +1,14 @@\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class AboutHTMLView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"about.html\")\n+    \n+class AboutMDView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"about.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/base.py b/src/snek/view/base.py\ndeleted file mode 100644\nindex d962ee5..0000000\n--- a/src/snek/view/base.py\n+++ /dev/null\n@@ -1,31 +0,0 @@\n-from aiohttp import web \n-\n-class BaseView(web.View):\n-    \n-    @property \n-    def app(self):\n-        return self.request.app\n-    \n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    def json_response(self, data):\n-        return web.json_response(data)\n-\n-    def render_template(self, template_name, context=None):\n-        return self.request.app.render_template(template_name, self.request,context)\n-    \n-class BaseFormView(BaseView):\n-\n-    form = None \n-\n-    async def get(self):\n-        form = self.form()\n-        return self.json_response(form.to_json())\n-       \n-    async def post(self):\n-        form = self.form()\n-        post = await self.request.json()\n-        form.set_user_data(post['form'])\n-        return self.json_response(form.to_json())  \n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex a5d8b92..c7861fa 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseView\n+from snek.system.view import BaseView\n \n class IndexView(BaseView):\n \ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 3a3beaf..ffedc79 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,5 +1,5 @@\n from snek.form.register import RegisterForm\n-from snek.view.base import BaseView \n+from snek.system.view import BaseView\n \n class LoginView(BaseView):\n \ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 26527da..e9b6eac 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseFormView\n+from snek.system.view import BaseFormView\n from snek.form.login import LoginForm\n \n class LoginFormView(BaseFormView):\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 095b7a3..e3b3038 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,4 +1,4 @@\n-from snek.view.base import BaseView \n+from snek.system.view import BaseView\n \n class RegisterView(BaseView):\n \ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 0ae7630..8099b01 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,5 +1,5 @@\n from snek.form.register import RegisterForm\n-from snek.view.base import BaseFormView \n+from snek.system.view import BaseFormView\n \n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n\\ No newline at end of file\ndiff --git a/src/snek/view/view.py b/src/snek/view/view.py\ndeleted file mode 100644\nindex ea642a3..0000000\n--- a/src/snek/view/view.py\n+++ /dev/null\n@@ -1,6 +0,0 @@\n-from snek.view.base import BaseView \n-\n-class WebView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"web.html\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nnew file mode 100644\nindex 0000000..b06563a\n--- /dev/null\n+++ b/src/snek/view/web.py\n@@ -0,0 +1,6 @@\n+from snek.system.view import BaseView\n+\n+class WebView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"web.html\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a", "diff": "commit 4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:05:47 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 5407a3b..a3e28f8 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -44,7 +44,9 @@ class FancyButton extends HTMLElement {\n         const me = this \n         this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n         this.buttonElement.addEventListener(\"click\",()=>{\n-            if(me.url){\n+            if(me.url == \"/back\" || me.url == \"/back/\"){\n+                window.history.back()\n+            }else if(me.url){\n                 window.location = me.url\n             }\n         })\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex 0f1b8a9..e403458 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -3,5 +3,5 @@\n {% block main %}\n \n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button text=\"Back\" url=\"/web.html\"></fancy-button>\n+<fancy-button text=\"Back\" url=\"/back\"></fancy-button>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1", "diff": "commit 0271e3f9719a4155fc5be37c36feca865932b0c1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:15:55 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex a3e28f8..5eec215 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -7,7 +7,18 @@ class FancyButton extends HTMLElement {\n     constructor(){\n         super()\n         this.attachShadow({mode:'open'})\n+    }\n+\n+    connectedCallback() {\n+\n         this.container = document.createElement('span')\n+        let size = this.getAttribute('size')\n+        console.info({GG:size})\n+        if(size == 'auto'){\n+           size = '1%' \n+        }else{\n+            size = '33%'\n+        }\n         this.styleElement = document.createElement(\"style\")\n         this.styleElement.innerHTML = `\n         :root {\n@@ -16,7 +27,7 @@ class FancyButton extends HTMLElement {\n     }\n         button {\n             width: var(--width);\n-            min-width: 33%;\n+            min-width: ${size};\n             padding: 10px;\n             border: none;\n@@ -26,19 +37,20 @@ class FancyButton extends HTMLElement {\n             font-weight: bold;\n             cursor: pointer;\n             transition: background-color 0.3s;\n+\n     }\n         button:hover {\n   }\n     `\n     this.container.appendChild(this.styleElement)\n         this.buttonElement = document.createElement('button')\n         this.container.appendChild(this.buttonElement)\n         this.shadowRoot.appendChild(this.container)\n-    }\n-\n-    connectedCallback() {\n+    \n         this.url = this.getAttribute('url');\n         this.value = this.getAttribute('value')\n         const me = this \ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex e403458..debba2a 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -3,5 +3,5 @@\n {% block main %}\n \n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button text=\"Back\" url=\"/back\"></fancy-button>\n+<fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated login and register button URLs", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0", "diff": "commit bda93e354f4691483dbbef29949672ab0989b7e0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:16:52 2025 +0100\n\n    Complete system.\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex c2200fb..8157f3d 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -11,9 +11,9 @@\n <body>\n   <div class=\"registration-container\">\n     <h1>Snek</h1>\n-    <fancy-button url=\"/login\" text=\"Login\"></fancy-button>\n+    <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">Or</span>\n-    <fancy-button url=\"/register\" text=\"Register\"></fancy-button>\n+    <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n     <a href=\"/web.html\">See web Application so far</a>\n   </div>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and layout improvements for responsive design", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b", "diff": "commit 757b67b78c2f396df7ac7b5706f98833aedfb85b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 14:47:19 2025 +0100\n\n    CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 363e4f1..263f1eb 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -4,6 +4,7 @@\n   box-sizing: border-box;\n }\n \n+\n body {\n   font-family: Arial, sans-serif;\n@@ -12,6 +13,10 @@ body {\n   display: flex;\n   flex-direction: column;\n   height: 100vh;\n+  min-width: 100%;\n+}\n+main {\n+  min-width: 100%;\n }\n \n header {\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 990fcf9..008a3ee 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,3 +1,27 @@\n+\n+* {\n+    margin: 0;\n+    padding: 0;\n+    box-sizing: border-box;\n+  }\n+\n+.center {\n+    padding: 30px;\n+    \n+    text-align: center;\n+    margin: auto auto;\n+    left: 25%;\n+    position: absolute;\n+  }\n+\n+  @media screen and (max-width: 500px) {\n+    .center {\n+        width: 100%;\n+        left: 0px;\n+    }\n+    \n+  }\n+\n h1 {\n     font-size: 2em;\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 9f57467..8637867 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -18,11 +18,6 @@\n \n     </header>\n     <main>\n-        <aside class=\"sidebar\">\n-        {% block sidebar %}\n-        \n-        {% endblock %}\n-    </aside>\n     {% block main %}\n     {% endblock %}\n </main>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex c70d429..2fe1a73 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,5 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/login-form.json\"></generic-form>\n+\n+   <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n+\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex f0a82e0..3668f13 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,5 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-  <generic-form url=\"/register-form.json\"></generic-form>\n+  <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved aesthetics and responsiveness", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065", "diff": "commit 6ba6121988dae019d4c4c0a3b8592b443f094065\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 15:20:35 2025 +0100\n\n    CSS.\n\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex a87f7d3..910cd73 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -21,4 +21,5 @@ class LoginForm(Form):\n         text=\"Login\",\n         type=\"button\"\n     )\n+   \n \ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 0d5d4c9..75f800e 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -30,9 +30,7 @@ class HTMLFrame extends HTMLElement {\n           const parent = this\n           const markdownElement = document.createElement('div')\n           markdownElement.innerHTML = html\n-          document.body.appendChild(markdownElement)\n-          \n+          this.outerHTML = html\n         }else{\n           this.container.innerHTML = html;\n         }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 008a3ee..d2cda8c 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,18 +1,18 @@\n \n * {\n-    margin: 0;\n-    padding: 0;\n+  \n     box-sizing: border-box;\n   }\n \n-.center {\n+  .dialog {\n+\n+    border-radius: 10px;\n     padding: 30px;\n-    \n-    text-align: center;\n-    margin: auto auto;\n-    left: 25%;\n-    position: absolute;\n-  }\n+    width: 800px;\n+    margin: 30px;\n+    box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+}\n \n   @media screen and (max-width: 500px) {\n     .center {\n@@ -34,8 +34,15 @@ h2 {\n     margin-bottom: 20px;\n }\n body {\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    justify-content: center;\n+    align-items: center;\n+    min-height: 100vh;\n \n }\n div {\ndiff --git a/src/snek/templates/about.html b/src/snek/templates/about.html\nindex debba2a..b68396e 100644\n--- a/src/snek/templates/about.html\n+++ b/src/snek/templates/about.html\n@@ -1,7 +1,10 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n+<div class=\"dialog\">\n \n+    <fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n <html-frame url=\"/about.md\"></html-frame>\n-<fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n+\n+</div>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 2fe1a73..3e72928 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,7 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n-\n+  <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n    <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 3668f13..b1540ea 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,5 +1,7 @@\n {% extends \"base.html\" %}\n \n {% block main %}\n+<fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+  \n   <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor login and register form endpoints to use /login.json and /register.json", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2", "diff": "commit c1eeacc0b415ef770418bb053d18ac0ffa4f64c2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:08:56 2025 +0100\n\n    Caching.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex deac5d3..db9ebf9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,7 +2,7 @@ import pathlib\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n-\n+from app.cache import time_cache_async\n from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n@@ -41,10 +41,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login-form.json\", LoginFormView)\n+        self.router.add_view(\"/login.json\", LoginFormView)\n         self.router.add_view(\"/register.html\", RegisterView)\n-        \n-        self.router.add_view(\"/register-form.json\", RegisterFormView)\n+        self.router.add_view(\"/register.json\", RegisterFormView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n@@ -65,6 +64,11 @@ class Application(BaseApplication):\n         return web.Response(\n             body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n         )\n+    \n+    \n+    @time_cache_async(60)\n+    async def render_template(self, template, request, context=None):\n+        return await super().render_template(template, request, context)\n \n \n app = Application()\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex bded949..93bc08c 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -8,7 +8,7 @@ from pygments import highlight\n from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n from pygments.styles import get_style_by_name\n-\n+import functools\n \n class MarkdownRenderer(HTMLRenderer):\n     def __init__(self, app, template):\n@@ -37,7 +37,11 @@ class MarkdownRenderer(HTMLRenderer):\n         return markdown(markdown_string)\n \n \n+@functools.cache\n+def render_markdown_sync(app, markdown_string):\n+    renderer = MarkdownRenderer(app,None)\n+    markdown = Markdown(renderer=renderer)\n+    return markdown(markdown_string)\n+\n async def render_markdown(app, markdown_string):\n-     renderer = MarkdownRenderer(app,None)\n-     markdown = Markdown(renderer=renderer)\n-     return markdown(markdown_string)\n\\ No newline at end of file\n+     return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex eb490b3..9a00186 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -81,8 +81,6 @@ class Validator:\n         self.regex = regex\n         self._value = None\n         self.value = value\n-        print(\"xxxx\", value, flush=True)\n-\n         self.kind = kind\n         self.help_text = help_text\n         self.__dict__.update(kwargs)\n@@ -106,7 +104,6 @@ class Validator:\n             error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n         if self.max_length is not None and len(self.value) > self.max_length:\n             error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n-        print(self.regex, self.value, flush=True)\n         if self.regex and self.value and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\")\n         if self.kind and not isinstance(self.value, self.kind):\n@@ -224,8 +221,6 @@ class BaseModel:\n         return self\n \n     def __init__(self, *args, **kwargs):\n-        print(self.__dict__)\n-        print(dir(self.__class__))\n         self._mapper = None\n         self.fields = {}\n         for key in dir(self.__class__):\n@@ -233,7 +228,6 @@ class BaseModel:\n \n             if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n-                print(\"JAAA\")\n                 self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n \n@@ -245,7 +239,6 @@ class BaseModel:\n     def __getattr__(self, key):\n         obj = self.__dict__.get(key)\n         if isinstance(obj, Validator):\n-            print(\"HPAPP\")\n             return obj.value\n         return obj\n \ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nindex 134fbc9..c28e9d0 100644\n--- a/src/snek/templates/about.md\n+++ b/src/snek/templates/about.md\n@@ -13,3 +13,57 @@ I made several design choices:\n  - Homebrew made Form framework based on the homebrew made ORM. Most forms are ModelForms but always require an service to be saved for sake of consistency and structure.\n  - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n  - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\n+\n+\n+A few examples of how the system framework works.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n\\ No newline at end of file\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 8637867..e00c886 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\n     <script src=\"/html-frame.js\"></script>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 3e72928..0a6bcc1 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -2,6 +2,6 @@\n \n {% block main %}\n   <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-   <generic-form class=\"center\" url=\"/login-form.json\"></generic-form>\n+   <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex b1540ea..f8d1067 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -3,5 +3,5 @@\n {% block main %}\n <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n   \n-  <generic-form class=\"center\" url=\"/register-form.json\"></generic-form>\n+  <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9", "diff": "commit 21ab5628b072320ae0851819116e57539b2397d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:09:10 2025 +0100\n\n    Caching.\n\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nnew file mode 100644\nindex 0000000..2992803\n--- /dev/null\n+++ b/src/snek/system/cache.py\n@@ -0,0 +1,17 @@\n+\n+import functools \n+\n+cache = functools.cache\n+\n+def async_cache(func):\n+    cache = {}\n+\n+    @functools.wraps(func)\n+    async def wrapper(*args):\n+        if args in cache:\n+            return cache[args]\n+        result = await func(*args)\n+        cache[args] = result\n+        return result\n+\n+    return wrapper\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Switch to gunicorn and add docs and about pages", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb", "diff": "commit 8486c22c325ba358bd48766c518f7c7bd30059eb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:33:27 2025 +0100\n\n    Disabled cache.\n\ndiff --git a/compose.yml b/compose.yml\nindex 3b1f650..24e186c 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,5 +6,6 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n    \n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex db9ebf9..f25ffba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -7,6 +7,7 @@ from jinja_markdown2 import MarkdownExtension\n from snek.system import http\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n@@ -39,6 +40,9 @@ class Application(BaseApplication):\n         )\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/docs.html\", DocsHTMLView)\n+        self.router.add_view(\"/docs.md\", DocsMDView)\n+        \n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\n@@ -66,7 +70,7 @@ class Application(BaseApplication):\n         )\n     \n     \n-    @time_cache_async(60)\n     async def render_template(self, template, request, context=None):\n         return await super().render_template(template, request, context)\n \ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 93bc08c..489a3e5 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -9,6 +9,7 @@ from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n from pygments.styles import get_style_by_name\n import functools\n+from app.cache import time_cache_async\n \n class MarkdownRenderer(HTMLRenderer):\n     def __init__(self, app, template):\n@@ -28,7 +29,6 @@ class MarkdownRenderer(HTMLRenderer):\n         lexer = get_lexer_by_name(lang, stripall=True)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        print(code, lang,info, flush=True)\n         return highlight(code, lexer, formatter)\n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\n@@ -37,11 +37,12 @@ class MarkdownRenderer(HTMLRenderer):\n         return markdown(markdown_string)\n \n \n-@functools.cache\n+\n def render_markdown_sync(app, markdown_string):\n     renderer = MarkdownRenderer(app,None)\n     markdown = Markdown(renderer=renderer)\n     return markdown(markdown_string)\n \n+@time_cache_async(120)\n async def render_markdown(app, markdown_string):\n      return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\ndiff --git a/src/snek/templates/about.md b/src/snek/templates/about.md\nindex c28e9d0..0b43fb4 100644\n--- a/src/snek/templates/about.md\n+++ b/src/snek/templates/about.md\n@@ -14,56 +14,3 @@ I made several design choices:\n  - !DRY for HMTL/jinja2 templates. For templates Snek does prefer to repeat itself to implement exceptions for a page easier. For Snek it's preffered do a few updates instead of maintaining a complex generic system that requires maintenance regarding templates.\n  - No existing chat backend like `inspircd` (Popular decent IRC server written in the language of angels) because I prefer to know what is exactly going on above performance and concurrency limit. Also, this approach reduces as networking layer / gateway layer.\n \n-\n-A few examples of how the system framework works.\n-\n-```python\n-\n-new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n-)\n-```\n-\n-```python\n-from snek.system import security\n-\n-var1 = security.encrypt(\"data\")\n-var2 = security.encrypt(b\"data\")\n-\n-assert(var1 == var2)\n-```\n-\n-```python\n-from snek.system.view import BaseView \n-\n-class IndexView(BaseView):\n-    \n-    async def get(self):\n-        return await self.render(\"index.html\")\n-```\n-```python\n-from snek.system.view import BaseFormView\n-from snek.form.register import RegisterForm\n-\n-class RegisterFormView(BaseFormView):\n-    \n-    form = RegisterForm\n-```\n-```python\n-app.routes.add_view(\"/your-page.html\", YourViewClass)\n-```\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 8157f3d..ad02a6a 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -15,7 +15,8 @@\n     <span style=\"padding:10px;\">Or</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n-    <a href=\"/web.html\">See web Application so far</a>\n+    <a href=\"/web.html\">App preview</a>\n+    <a href=\"/docs.html\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added API documentation and corresponding views", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353", "diff": "commit aecd9f844ef0a277a55aa536db3336362e8db353\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:34:02 2025 +0100\n\n    Docs.\n\ndiff --git a/src/snek/templates/docs.html b/src/snek/templates/docs.html\nnew file mode 100644\nindex 0000000..21c0309\n--- /dev/null\n+++ b/src/snek/templates/docs.html\n@@ -0,0 +1,10 @@\n+{% extends \"base.html\" %}\n+\n+{% block main %}\n+<div class=\"dialog\">\n+\n+    <fancy-button size=\"auto\"  text=\"Back\" url=\"/back\"></fancy-button>\n+<html-frame url=\"/docs.md\"></html-frame>\n+\n+</div>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/docs.md b/src/snek/templates/docs.md\nnew file mode 100644\nindex 0000000..2cf60cb\n--- /dev/null\n+++ b/src/snek/templates/docs.md\n@@ -0,0 +1,53 @@\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nnew file mode 100644\nindex 0000000..e69d754\n--- /dev/null\n+++ b/src/snek/view/docs.py\n@@ -0,0 +1,15 @@\n+\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class DocsHTMLView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.html\")\n+    \n+class DocsMDView(BaseView):\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.md\")\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog elements", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c", "diff": "commit be9489f939b3518f9c3a73b9e54ba0f9d34ae24c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 16:43:57 2025 +0100\n\n    Docs.\n\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex d2cda8c..63a28ed 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -19,6 +19,10 @@\n         width: 100%;\n         left: 0px;\n     }\n+    .dialog {\n+        width: 100%;\n+        left: 0px;\n+    }\n     \n   }"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb", "diff": "commit 2ba55f692dfc1b60ac55d514b182fb8834cb99bb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 21:19:03 2025 +0100\n\n    Finished register.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f25ffba..0cca574 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -4,6 +4,8 @@ from aiohttp import web\n from app.app import Application as BaseApplication\n from app.cache import time_cache_async\n from jinja_markdown2 import MarkdownExtension\n+from snek.mapper import get_mappers\n+from snek.service import get_services\n from snek.system import http\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -14,6 +16,7 @@ from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.web import WebView\n+from types import SimpleNamespace\n \n \n class Application(BaseApplication):\n@@ -29,6 +32,11 @@ class Application(BaseApplication):\n         )\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n+        self.setup_services()\n+\n+    def setup_services(self):\n+        self.services = SimpleNamespace(**get_services(app=self))\n+        self.mappers = SimpleNamespace(**get_mappers(app=self))\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 910cd73..0d97d41 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -22,4 +22,3 @@ class LoginForm(Form):\n         type=\"button\"\n     )\n    \n-\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 7dff3e4..4252bf1 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,10 +1,19 @@\n from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n \n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n class RegisterForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Register\")\n \n-    username = FormInputElement(\n+    username = UsernameField(\n         name=\"username\", \n         required=True,\n         min_length=2,\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 5b8671e..642028c 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -3,4 +3,4 @@ from snek.model.user import UserModel\n \n class UserMapper(BaseMapper):\n     table_name = \"user\"\n-    model: UserModel \n\\ No newline at end of file\n+    model_class = UserModel \n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex cde4b8c..a2b0cb1 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,13 +1,14 @@\n from snek.system.service import BaseService \n from snek.system import security \n \n-class UserService:\n+class UserService(BaseService):\n     mapper_name = \"user\"\n \n-    async def create_user(self, username, password):\n+    async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n+        model.email = email\n         model.username = username\n         model.password = await security.hash(password)\n         if await self.save(model):\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 58a67e2..a47c6db 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -280,7 +280,11 @@ class GenericForm extends HTMLElement {\n             if(e.detail.type == \"button\"){\n               if(e.detail.value == \"submit\")\n               {\n-                await me.validate()\n+                const isValid = await me.validate()\n+                if(isValid){\n+                  const isProcessed = await me.submit()\n+                  console.info({processed:isProcessed})\n+                }\n               }\n             }\n               \n@@ -294,13 +298,15 @@ class GenericForm extends HTMLElement {\n     async validate(){\n       const url = this.getAttribute(\"url\")\n       const me = this\n-      const response = await fetch(url,{\n+      let response = await fetch(url,{\n         method: 'POST',\n         headers: {\n           'Content-Type': 'application/json'\n         },\n         body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n       });\n+\n+      \n       const form = await response.json()\n       Object.values(form.fields).forEach(field=>{\n         if(!me.form.fields[field.name])\n@@ -320,6 +326,22 @@ class GenericForm extends HTMLElement {\n         console.info(field.errors)\n         me.fields[field.name].setErrors(field.errors)\n       })\n+      console.info({XX:form})\n+      return form['is_valid']\n+    }\n+    async submit(){\n+      const me = this \n+      const url = me.getAttribute(\"url\")\n+      const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify({\"action\":\"submit\", \"form\":me.form})\n+      });\n+      return await response.json()\n+    \n     }\n+    \n   }\n   customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex f9ebebb..82091b6 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -35,8 +35,8 @@ class HTMLElement(model.ModelField):\n         self.html = html\n         super().__init__(name=name, *args, **kwargs)\n \n-    def to_json(self):\n-        result = super().to_json()\n+    async def to_json(self):\n+        result = await super().to_json()\n         result['text'] = self.text\n         result['id'] = self.id\n         result['html'] = self.html\n@@ -53,8 +53,8 @@ class FormInputElement(FormElement):\n         self.place_holder = place_holder\n         self.type = type\n \n-    def to_json(self):\n-        data = super().to_json()\n+    async def to_json(self):\n+        data = await super().to_json()\n         data[\"place_holder\"] = self.place_holder\n         data[\"type\"] = self.type\n         return data\n@@ -66,31 +66,36 @@ class FormButtonElement(FormElement):\n class Form(model.BaseModel):\n     @property\n     def html_elements(self):\n-        json_elements = super().to_json()\n         return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n     def set_user_data(self, data):\n         return super().set_user_data(data.get('fields'))\n \n-    def to_json(self, encode=False):\n-        elements = super().to_json()\n+    async def to_json(self, encode=False):\n+        elements = await super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n+            if element == 'is_valid':\n+                continue \n             field = getattr(self, element)\n             if isinstance(field, HTMLElement):\n                 try:\n                     html_elements[element] = elements[element]\n                 except KeyError:\n                     pass\n-        return dict(fields=html_elements, is_valid=self.is_valid, errors=self.errors)\n+\n+        is_valid = all(field['is_valid'] for field in html_elements.values())\n+        return dict(fields=html_elements, is_valid=is_valid, errors=await self.errors)\n \n     @property\n-    def errors(self):\n+    async def errors(self):\n         result = []\n         for field in self.html_elements:\n-            result += field.errors\n+            result += await field.errors\n         return result\n \n     @property\n-    def is_valid(self):\n-        return all(element.is_valid for element in self.html_elements)\n\\ No newline at end of file\n+    async def is_valid(self):\n+        return False\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex f4beb2e..667aad1 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,25 +1,19 @@\n \n DEFAULT_LIMIT = 30\n+import typing\n from snek.system.model import BaseModel\n-from snek.app import Application \n+\n import types\n \n-class Mapper:\n+class BaseMapper:\n \n     model_class:BaseModel = None \n     default_limit:int = DEFAULT_LIMIT\n     table_name:str = None \n \n-    def __init__(self, app:Application, table_name:str, model_class:BaseModel):\n+    def __init__(self, app):\n         self.app = app \n         \n-        if not self.model_class:\n-            raise ValueError(\"Mapper configuration error: model_class is not set.\")\n-        self.model_class = model_class \n-        \n-        self.table_name = table_name\n-        if not self.table_name:\n-            raise ValueError(\"Mapper configuration error: table_name is not set.\")\n         self.default_limit = self.__class__.default_limit \n     \n     @property\n@@ -33,12 +27,12 @@ class Mapper:\n     def table(self):\n         return self.db[self.table_name]\n \n-    async def get(self, uid:str=None, **kwargs) -> types.Optional[BaseModel]\n+    async def get(self, uid:str=None, **kwargs) -> BaseModel:\n         if uid:\n             kwargs['uid'] = uid \n         model = self.new()\n         record = self.table.find_one(**kwargs)\n-        return self.model_class.from_record(mapper=self,record=record)\n+        return await self.model_class.from_record(mapper=self,record=record)\n \n     async def exists(self, **kwargs):\n         return self.table.exists(**kwargs)\n@@ -47,16 +41,16 @@ class Mapper:\n         return self.table.count(**kwargs)\n \n     async def save(self, model:BaseModel) -> bool:\n-        record = model.record\n+        record = await model.record\n         if not record.get('uid'):\n             raise Exception(f\"Attempt to save without uid: {record}.\")\n         return self.table.upsert(record,['uid'])\n \n-    async def find(self, **kwargs) -> types.List[BaseModel]:\n+    async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield self.model_class.from_record(mapper=self,record=record)\n+            yield await self.model_class.from_record(mapper=self,record=record)\n     \n     async def delete(self, kwargs=None)-> int: \n         if not kwargs or not isinstance(kwargs, dict):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 9a00186..0d700ff 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -70,9 +70,11 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, **kwargs):\n+    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, app=None, model=None, **kwargs):\n         self.index = Validator._index\n         Validator._index += 1\n+        self.app = app\n+        self.model = model\n         self.required = required\n         self.min_num = min_num\n         self.max_num = max_num\n@@ -86,7 +88,7 @@ class Validator:\n         self.__dict__.update(kwargs)\n \n     @property\n-    def errors(self):\n+    async def errors(self):\n         error_list = []\n         if self.value is None and self.required:\n             error_list.append(\"Field is required.\")\n@@ -110,20 +112,23 @@ class Validator:\n             error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n         return error_list\n \n-    def validate(self):\n-        if self.errors:\n-            raise ValueError(\"\\n\", self.errors)\n+    async def validate(self):\n+        errors = await self.errors\n+        if errors:\n+            raise ValueError(f\"Errors: {errors}.\")\n         return True\n \n     @property\n-    def is_valid(self):\n+    async def is_valid(self):\n         try:\n-            self.validate()\n+            await self.validate()\n             return True\n         except ValueError:\n             return False\n \n-    def to_json(self):\n+    async def to_json(self):\n+        errors = await self.errors\n+        is_valid = await self.is_valid\n         return {\n             \"required\": self.required,\n             \"min_num\": self.min_num,\n@@ -134,8 +139,8 @@ class Validator:\n             \"value\": self.value,\n             \"kind\": str(self.kind),\n             \"help_text\": self.help_text,\n-            \"errors\": self.errors,\n-            \"is_valid\": self.is_valid,\n+            \"errors\": errors,\n+            \"is_valid\": is_valid,\n             \"index\": self.index\n         }\n \n@@ -149,8 +154,8 @@ class ModelField(Validator):\n         self.save = save\n         super().__init__(*args, **kwargs)\n \n-    def to_json(self):\n-        result = super().to_json()\n+    async def to_json(self):\n+        result = await super().to_json()\n         result['name'] = self.name\n         return result\n \n@@ -193,7 +198,7 @@ class BaseModel:\n     deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n \n     @classmethod \n-    def from_record(cls, record, mapper):\n+    async def from_record(cls, record, mapper):\n         model = cls.__new__()\n         model.mapper = mapper \n         model.record = record\n@@ -230,6 +235,8 @@ class BaseModel:\n                 self.__dict__[key] = copy.deepcopy(obj)\n                 self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n                 self.fields[key] = self.__dict__[key]\n+                self.fields[key].model = self\n+                self.fields[key].app = kwargs.get('app')\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -254,11 +261,9 @@ class BaseModel:\n     \n \n     @property\n-    def is_valid(self):\n-        for field in self.fields.values():\n-            if not field.is_valid:\n-                return False\n-        return True\n+    async def is_valid(self):\n+        return all([await field.is_valid for field in self.fields.values()])\n+        \n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -273,28 +278,31 @@ class BaseModel:\n             self.__dict__[key] = value\n \n     @property\n-    def record(self):\n-        obj = self.to_json()\n+    async def record(self):\n+        obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\n+            if not isinstance(value, dict) or not 'value' in value:\n+                continue\n             if getattr(self, key).save:\n                 record[key] = value.get('value')\n         return record\n \n-    def to_json(self, encode=False):\n+    async def to_json(self, encode=False):\n         model_data = OrderedDict({\n             \"uid\": self.uid.value,\n             \"created_at\": self.created_at.value,\n             \"updated_at\": self.updated_at.value,\n-            \"deleted_at\": self.deleted_at.value\n+            \"deleted_at\": self.deleted_at.value,\n+            \"is_valid\": await self.is_valid\n         })\n \n-        for key, value in self.__dict__.items():\n+        for key, value in self.fields.items():\n             if key == \"record\":\n                 continue\n             value = self.__dict__[key]\n             if hasattr(value, \"value\"):\n-                model_data[key] = value.to_json()\n+                model_data[key] = await value.to_json()\n         if encode:\n             return json.dumps(model_data, indent=2)\n         return model_data\n@@ -313,8 +321,8 @@ class FormElement(ModelField):\n         self.place_holder = place_holder\n         super().__init__(*args, **kwargs)\n \n-    def to_json(self):\n-        data = super().to_json()\n+    async def to_json(self):\n+        data = await super().to_json()\n         data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n         return data\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 5a8b553..d970b5f 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -17,10 +17,10 @@ class BaseService:\n             self.mapper = None \n \n     async def exists(self, **kwargs):\n-        return self.mapper.exists(**kwargs)\n+        return await self.count(**kwargs) > 0\n     \n     async def count(self, **kwargs):\n-        return self.mapper.count(**kwargs)\n+        return await self.mapper.count(**kwargs)\n \n     async def new(self, **kwargs):\n         return await self.mapper.new()\n@@ -29,9 +29,9 @@ class BaseService:\n         return await self.mapper.get(**kwargs)\n     \n     async def save(self, model:UserModel):\n-        if model.is_valid:\n-            return self.mapper.save(model) and True \n-        return False \n+        return await self.mapper.save(model) and True \n+         \n     \n     async def find(self, **kwargs):\n         return await self.mapper.find(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 458aa20..3b53f33 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -27,12 +27,21 @@ class BaseFormView(BaseView):\n     form = None \n \n     async def get(self):\n-        form = self.form()\n-        return await self.json_response(form.to_json())\n+        form = self.form(app=self.app)\n+        \n+        return await self.json_response(await form.to_json())\n        \n     async def post(self):\n-        form = self.form()\n+        form = self.form(app=self.app)\n         post = await self.request.json()\n         form.set_user_data(post['form'])\n-        return await self.json_response(form.to_json())  \n+        result = await form.to_json()\n+        if post.get('action') == 'validate':\n+            pass\n+        if post.get('action') == 'submit' and result['is_valid']:\n+            await self.submit(form)\n+        return await self.json_response(result) \n \n+    async def submit(self,model=None):\n+        print(\"Submit sucess\")\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 8099b01..8cb6567 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -2,4 +2,8 @@ from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n class RegisterFormView(BaseFormView):\n-    form = RegisterForm\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(form.email.value,form.username.value,form.password.value)\n+        print(\"SUBMITTED:\",result)\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation subapp and markdown extension", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14", "diff": "commit 18b76ebd5e2f11451db04800d426a16b1ef1dd14\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:33:36 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0cca574..78c0e2b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,11 +2,12 @@ import pathlib\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n+from snek.docs.app import Application as DocsApplication\n from app.cache import time_cache_async\n-from jinja_markdown2 import MarkdownExtension\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n+from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n@@ -59,6 +60,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n+        self.add_subapp(\"/docs\", DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")))\n+\n     async def handle_test(self, request):\n \n         return await self.render_template(\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 489a3e5..fcffe40 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,6 +1,7 @@\n \n \n+from types import SimpleNamespace\n from mistune import escape\n from mistune import Markdown\n from mistune import HTMLRenderer\n@@ -12,6 +13,8 @@ import functools\n from app.cache import time_cache_async\n \n class MarkdownRenderer(HTMLRenderer):\n+\n+    _allow_harmful_protocols = True\n     def __init__(self, app, template):\n          self.template = template\n          \n@@ -45,4 +48,29 @@ def render_markdown_sync(app, markdown_string):\n \n @time_cache_async(120)\n async def render_markdown(app, markdown_string):\n-     return render_markdown_sync(app,markdown_string)\n\\ No newline at end of file\n+     return render_markdown_sync(app,markdown_string)\n+\n+from jinja2 import nodes, TemplateSyntaxError\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n+\n+class MarkdownExtension(Extension):\n+    tags = {'markdown'}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(MarkdownExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const('')]\n+        body = ''\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements(['name:endmarkdown'], drop_needle=True)\n+        return nodes.CallBlock(self.call_method('_to_html', md_file), [], [], body).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return render_markdown_sync(self.app,caller())\n\\ No newline at end of file\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex ad02a6a..c6d57df 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -16,7 +16,7 @@\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n     <a href=\"/about.html\">Design choices</a>\n     <a href=\"/web.html\">App preview</a>\n-    <a href=\"/docs.html\">API docs</a>\n+    <a href=\"/docs/docs/\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f", "diff": "commit 9b93403a93ac0b03a57fb5dc10db5c35349c4d6f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:35:44 2025 +0100\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 78c0e2b..ab19f42 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,9 +1,10 @@\n import pathlib\n+from types import SimpleNamespace\n \n from aiohttp import web\n from app.app import Application as BaseApplication\n+\n from snek.docs.app import Application as DocsApplication\n-from app.cache import time_cache_async\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n@@ -17,7 +18,6 @@ from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.web import WebView\n-from types import SimpleNamespace\n \n \n class Application(BaseApplication):\n@@ -51,7 +51,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-        \n+\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\n@@ -60,7 +60,10 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \n-        self.add_subapp(\"/docs\", DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")))\n+        self.add_subapp(\n+            \"/docs\",\n+            DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n+        )\n \n     async def handle_test(self, request):\n \n@@ -79,9 +82,8 @@ class Application(BaseApplication):\n         return web.Response(\n             body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n         )\n-    \n-    \n+\n     async def render_template(self, template, request, context=None):\n         return await super().render_template(template, request, context)\n \ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 0d97d41..3d6d9a7 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,24 +1,27 @@\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n \n class LoginForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Login\")\n \n     username = FormInputElement(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n         place_holder=\"Username\",\n-        type=\"text\"\n+        type=\"text\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\",\n     )\n-    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n \n     action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Login\",\n-        type=\"button\"\n+        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n     )\n-   \ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 4252bf1..1384b8f 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,4 +1,5 @@\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n \n class UsernameField(FormInputElement):\n \n@@ -9,32 +10,35 @@ class UsernameField(FormInputElement):\n             result.append(\"Username is not available.\")\n         return result\n \n+\n class RegisterForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Register\")\n \n     username = UsernameField(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n         place_holder=\"Username\",\n-        type=\"text\"\n+        type=\"text\",\n     )\n     email = FormInputElement(\n         name=\"email\",\n         required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n         place_holder=\"Email address\",\n-        type=\"email\"\n+        type=\"email\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\",\n     )\n-    password = FormInputElement(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\",type=\"password\",place_holder=\"Password\")\n \n     action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Register\",\n-        type=\"button\"\n+        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n     )\n-\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex dc9e047..2b9b79f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,12 +1,12 @@\n-import functools \n+import functools\n+\n from snek.mapper.user import UserMapper\n \n-@functools.cache \n+\n+@functools.cache\n def get_mappers(app=None):\n-    return dict(\n-        user=UserMapper(app=app)\n+    return {\"user\": UserMapper(app=app)}\n \n-    )\n \n def get_mapper(name, app=None):\n-    return get_mappers(app=app)[name]\n\\ No newline at end of file\n+    return get_mappers(app=app)[name]\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 642028c..c388abc 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,6 +1,7 @@\n-from snek.system.mapper import BaseMapper\n from snek.model.user import UserModel\n+from snek.system.mapper import BaseMapper\n+\n \n class UserMapper(BaseMapper):\n     table_name = \"user\"\n-    model_class = UserModel \n\\ No newline at end of file\n+    model_class = UserModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 52af21a..081ae15 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,12 +1,12 @@\n-from snek.model.user import UserModel \n-import functools \n+import functools\n+\n+from snek.model.user import UserModel\n+\n \n @functools.cache\n def get_models():\n-    return dict(\n-        user=UserModel\n+    return {\"user\": UserModel}\n \n-    )\n \n def get_model(name):\n     return get_models()[name]\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 254b6c9..adb236b 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,9 +1,10 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n \n class UserModel(BaseModel):\n-    \n+\n     username = ModelField(\n-        name=\"username\", \n+        name=\"username\",\n         required=True,\n         min_length=2,\n         max_length=20,\n@@ -12,8 +13,6 @@ class UserModel(BaseModel):\n     email = ModelField(\n         name=\"email\",\n         required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\"\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n-    password = ModelField(name=\"password\",required=True,regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n-\n-\n+    password = ModelField(name=\"password\", required=True, regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 4038f70..60fec76 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,12 +1,13 @@\n-from snek.service.user import UserService \n-import functools \n+import functools\n+\n+from snek.service.user import UserService\n+\n \n @functools.cache\n def get_services(app):\n \n-    return dict(\n-        user = UserService(app=app)\n+    return {\"user\": UserService(app=app)}\n+\n \n-    )\n def get_service(name, app=None):\n-    return get_services(app=app)[name]\n\\ No newline at end of file\n+    return get_services(app=app)[name]\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex a2b0cb1..5124640 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,5 +1,6 @@\n-from snek.system.service import BaseService \n-from snek.system import security \n+from snek.system import security\n+from snek.system.service import BaseService\n+\n \n class UserService(BaseService):\n     mapper_name = \"user\"\n@@ -12,6 +13,5 @@ class UserService(BaseService):\n         model.username = username\n         model.password = await security.hash(password)\n         if await self.save(model):\n-            return model \n+            return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\n-        \n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 2992803..5e275d9 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,8 +1,8 @@\n-\n-import functools \n+import functools\n \n cache = functools.cache\n \n+\n def async_cache(func):\n     cache = {}\n \n@@ -14,4 +14,4 @@ def async_cache(func):\n         cache[args] = result\n         return result\n \n-    return wrapper\n\\ No newline at end of file\n+    return wrapper\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 82091b6..f4cf2d3 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -5,17 +5,17 @@\n \n@@ -26,8 +26,19 @@\n \n from snek.system import model\n \n+\n class HTMLElement(model.ModelField):\n-    def __init__(self, id=None, tag=\"div\", name=None, html=None, class_name=None, text=None, *args, **kwargs):\n+    def __init__(\n+        self,\n+        id=None,\n+        tag=\"div\",\n+        name=None,\n+        html=None,\n+        class_name=None,\n+        text=None,\n+        *args,\n+        **kwargs,\n+    ):\n         self.tag = tag\n         self.text = text\n         self.id = id\n@@ -37,16 +48,18 @@ class HTMLElement(model.ModelField):\n \n     async def to_json(self):\n         result = await super().to_json()\n-        result['text'] = self.text\n-        result['id'] = self.id\n-        result['html'] = self.html\n-        result['class_name'] = self.class_name\n-        result['tag'] = self.tag\n+        result[\"text\"] = self.text\n+        result[\"id\"] = self.id\n+        result[\"html\"] = self.html\n+        result[\"class_name\"] = self.class_name\n+        result[\"tag\"] = self.tag\n         return result\n \n+\n class FormElement(HTMLElement):\n     pass\n \n+\n class FormInputElement(FormElement):\n     def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n         super().__init__(tag=\"input\", *args, **kwargs)\n@@ -59,25 +72,27 @@ class FormInputElement(FormElement):\n         data[\"type\"] = self.type\n         return data\n \n+\n class FormButtonElement(FormElement):\n     def __init__(self, tag=\"button\", *args, **kwargs):\n         super().__init__(tag=tag, *args, **kwargs)\n \n+\n class Form(model.BaseModel):\n     @property\n     def html_elements(self):\n         return [element for element in self.fields if isinstance(element, HTMLElement)]\n \n     def set_user_data(self, data):\n-        return super().set_user_data(data.get('fields'))\n+        return super().set_user_data(data.get(\"fields\"))\n \n     async def to_json(self, encode=False):\n         elements = await super().to_json()\n         html_elements = {}\n         for element in elements.keys():\n-            if element == 'is_valid':\n+            if element == \"is_valid\":\n-                continue \n+                continue\n             field = getattr(self, element)\n             if isinstance(field, HTMLElement):\n                 try:\n@@ -85,8 +100,12 @@ class Form(model.BaseModel):\n                 except KeyError:\n                     pass\n \n-        is_valid = all(field['is_valid'] for field in html_elements.values())\n-        return dict(fields=html_elements, is_valid=is_valid, errors=await self.errors)\n+        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n+        return {\n+            \"fields\": html_elements,\n+            \"is_valid\": is_valid,\n+            \"errors\": await self.errors,\n+        }\n \n     @property\n     async def errors(self):\n@@ -98,4 +117,4 @@ class Form(model.BaseModel):\n     @property\n     async def is_valid(self):\n-        return False\n\\ No newline at end of file\n+        return False\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex b5e8b4f..cd8a9b1 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -11,10 +11,10 @@\n@@ -24,17 +24,17 @@\n \n \n-from aiohttp import web\n-import aiohttp\n-from app.cache import time_cache_async\n-from bs4 import BeautifulSoup\n-from urllib.parse import urljoin\n+import asyncio\n import pathlib\n import uuid\n-import imgkit\n-import asyncio\n import zlib\n-import io\n+from urllib.parse import urljoin\n+\n+import aiohttp\n+import imgkit\n+from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n+\n \n async def crc32(data):\n     try:\n@@ -43,6 +43,7 @@ async def crc32(data):\n         pass\n     return \"crc32\" + str(zlib.crc32(data))\n \n+\n async def get_file(name, suffix=\".cache\"):\n     name = await crc32(name)\n     path = pathlib.Path(\".\").joinpath(\"cache\")\n@@ -50,17 +51,19 @@ async def get_file(name, suffix=\".cache\"):\n         path.mkdir(parents=True, exist_ok=True)\n     return path.joinpath(name + suffix)\n \n+\n async def public_touch(name=None):\n     path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n     path.open(\"wb\").close()\n     return path\n \n+\n async def create_site_photo(url):\n     loop = asyncio.get_event_loop()\n     if not url.startswith(\"https\"):\n     output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n-    \n+\n     if output_path.exists():\n         return output_path\n     output_path.touch()\n@@ -71,21 +74,23 @@ async def create_site_photo(url):\n \n     return await loop.run_in_executor(None, make_photo)\n \n+\n async def repair_links(base_url, html_content):\n     soup = BeautifulSoup(html_content, \"html.parser\")\n-    for tag in soup.find_all(['a', 'img', 'link']):\n-        if tag.has_attr('href') and not tag['href'].startswith(\"http\"):\n-            tag['href'] = urljoin(base_url, tag['href'])\n-        if tag.has_attr('src') and not tag['src'].startswith(\"http\"):\n-            tag['src'] = urljoin(base_url, tag['src'])\n+    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n+        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n+            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n+        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n+            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n     return soup.prettify()\n \n+\n async def is_html_content(content: bytes):\n     try:\n-        content = content.decode(errors='ignore')\n+        content = content.decode(errors=\"ignore\")\n     except:\n         pass\n-    marks = ['<html', '<img', '<p', '<span', '<div']\n+    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n     try:\n         content = content.lower()\n         for mark in marks:\n@@ -95,6 +100,7 @@ async def is_html_content(content: bytes):\n         print(ex)\n     return False\n \n+\n @time_cache_async(120)\n async def get(url):\n     async with aiohttp.ClientSession() as session:\n@@ -102,4 +108,4 @@ async def get(url):\n         content = await response.text()\n         if await is_html_content(content):\n             content = (await repair_links(url, content)).encode()\n-        return content\n\\ No newline at end of file\n+        return content\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 667aad1..f6c6200 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,23 +1,22 @@\n-\n DEFAULT_LIMIT = 30\n import typing\n+\n from snek.system.model import BaseModel\n \n-import types\n \n class BaseMapper:\n \n-    model_class:BaseModel = None \n-    default_limit:int = DEFAULT_LIMIT\n-    table_name:str = None \n+    model_class: BaseModel = None\n+    default_limit: int = DEFAULT_LIMIT\n+    table_name: str = None\n \n     def __init__(self, app):\n-        self.app = app \n-        \n-        self.default_limit = self.__class__.default_limit \n-    \n+        self.app = app\n+\n+        self.default_limit = self.__class__.default_limit\n+\n     @property\n-    def db(self): \n+    def db(self):\n         return self.app.db\n \n     async def new(self):\n@@ -27,12 +26,12 @@ class BaseMapper:\n     def table(self):\n         return self.db[self.table_name]\n \n-    async def get(self, uid:str=None, **kwargs) -> BaseModel:\n+    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n         if uid:\n-            kwargs['uid'] = uid \n-        model = self.new()\n+            kwargs[\"uid\"] = uid\n+        self.new()\n         record = self.table.find_one(**kwargs)\n-        return await self.model_class.from_record(mapper=self,record=record)\n+        return await self.model_class.from_record(mapper=self, record=record)\n \n     async def exists(self, **kwargs):\n         return self.table.exists(**kwargs)\n@@ -40,19 +39,19 @@ class BaseMapper:\n     async def count(self, **kwargs) -> int:\n         return self.table.count(**kwargs)\n \n-    async def save(self, model:BaseModel) -> bool:\n+    async def save(self, model: BaseModel) -> bool:\n         record = await model.record\n-        if not record.get('uid'):\n+        if not record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {record}.\")\n-        return self.table.upsert(record,['uid'])\n+        return self.table.upsert(record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield await self.model_class.from_record(mapper=self,record=record)\n-    \n-    async def delete(self, kwargs=None)-> int: \n+            yield await self.model_class.from_record(mapper=self, record=record)\n+\n+    async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\n         return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex fcffe40..23d0656 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,62 +1,65 @@\n-\n \n from types import SimpleNamespace\n-from mistune import escape\n-from mistune import Markdown\n-from mistune import HTMLRenderer\n+\n+from app.cache import time_cache_async\n+from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n-from pygments.lexers import get_lexer_by_name\n from pygments.formatters import html\n-from pygments.styles import get_style_by_name\n-import functools\n-from app.cache import time_cache_async\n+from pygments.lexers import get_lexer_by_name\n+\n \n class MarkdownRenderer(HTMLRenderer):\n \n     _allow_harmful_protocols = True\n+\n     def __init__(self, app, template):\n-         self.template = template\n-         \n-         self.app = app\n-         self.env = self.app.jinja2_env\n-         formatter = html.HtmlFormatter()\n-         self.env.globals['highlight_styles'] = formatter.get_style_defs()\n-    def _escape(self,str):\n-    def block_code(self, code, lang=None,info=None):\n+        self.template = template\n+\n+        self.app = app\n+        self.env = self.app.jinja2_env\n+        formatter = html.HtmlFormatter()\n+        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n+\n+    def _escape(self, str):\n+\n+    def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n             return f\"<div>{code}</div>\"\n         lexer = get_lexer_by_name(lang, stripall=True)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         return highlight(code, lexer, formatter)\n+\n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\n-        renderer = MarkdownRenderer(self.app,self.template)\n+        renderer = MarkdownRenderer(self.app, self.template)\n         markdown = Markdown(renderer=renderer)\n         return markdown(markdown_string)\n \n \n-\n def render_markdown_sync(app, markdown_string):\n-    renderer = MarkdownRenderer(app,None)\n+    renderer = MarkdownRenderer(app, None)\n     markdown = Markdown(renderer=renderer)\n     return markdown(markdown_string)\n \n+\n @time_cache_async(120)\n async def render_markdown(app, markdown_string):\n-     return render_markdown_sync(app,markdown_string)\n+    return render_markdown_sync(app, markdown_string)\n \n-from jinja2 import nodes, TemplateSyntaxError\n+\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n+\n class MarkdownExtension(Extension):\n-    tags = {'markdown'}\n+    tags = {\"markdown\"}\n \n     def __init__(self, environment):\n         self.app = SimpleNamespace(jinja2_env=environment)\n@@ -64,13 +67,15 @@ class MarkdownExtension(Extension):\n \n     def parse(self, parser):\n         line_number = next(parser.stream).lineno\n-        md_file = [Const('')]\n-        body = ''\n+        md_file = [Const(\"\")]\n+        body = \"\"\n         try:\n             md_file = [parser.parse_expression()]\n         except TemplateSyntaxError:\n-            body = parser.parse_statements(['name:endmarkdown'], drop_needle=True)\n-        return nodes.CallBlock(self.call_method('_to_html', md_file), [], [], body).set_lineno(line_number)\n+            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return render_markdown_sync(self.app,caller())\n\\ No newline at end of file\n+        return render_markdown_sync(self.app, caller())\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 7fe457f..69fe378 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -8,12 +8,14 @@\n \n from aiohttp import web\n \n+\n @web.middleware\n async def no_cors_middleware(request, handler):\n     response = await handler(request)\n     response.headers.pop(\"Access-Control-Allow-Origin\", None)\n     return response\n \n+\n @web.middleware\n async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n@@ -22,12 +24,15 @@ async def cors_allow_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n     return response\n \n+\n @web.middleware\n async def cors_middleware(request, handler):\n     if request.method == \"OPTIONS\":\n         response = web.Response()\n         response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+        response.headers[\"Access-Control-Allow-Methods\"] = (\n+            \"GET, POST, PUT, DELETE, OPTIONS\"\n+        )\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n         return response\n \n@@ -35,4 +40,4 @@ async def cors_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    return response\n\\ No newline at end of file\n+    return response\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 0d700ff..b41f4ba 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -25,12 +25,12 @@\n \n \n+import copy\n+import json\n import re\n import uuid\n-import json\n-from datetime import datetime, timezone\n from collections import OrderedDict\n-import copy\n+from datetime import datetime, timezone\n \n TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n \n@@ -44,12 +44,21 @@ def add_attrs(**kwargs):\n         for key, value in kwargs.items():\n             setattr(func, key, value)\n         return func\n+\n     return decorator\n \n \n-def validate_attrs(required=False, min_length=None, max_length=None, regex=None, **kwargs):\n+def validate_attrs(\n+    required=False, min_length=None, max_length=None, regex=None, **kwargs\n+):\n     def decorator(func):\n-        return add_attrs(required=required, min_length=min_length, max_length=max_length, regex=regex, **kwargs)(func)\n+        return add_attrs(\n+            required=required,\n+            min_length=min_length,\n+            max_length=max_length,\n+            regex=regex,\n+            **kwargs,\n+        )(func)\n \n \n class Validator:\n@@ -70,7 +79,21 @@ class Validator:\n     def custom_validation(self):\n         return True\n \n-    def __init__(self, required=False, min_num=None, max_num=None, min_length=None, max_length=None, regex=None, value=None, kind=None, help_text=None, app=None, model=None, **kwargs):\n+    def __init__(\n+        self,\n+        required=False,\n+        min_num=None,\n+        max_num=None,\n+        min_length=None,\n+        max_length=None,\n+        regex=None,\n+        value=None,\n+        kind=None,\n+        help_text=None,\n+        app=None,\n+        model=None,\n+        **kwargs,\n+    ):\n         self.index = Validator._index\n         Validator._index += 1\n         self.app = app\n@@ -103,9 +126,13 @@ class Validator:\n             if self.max_num is not None and self.value > self.max_num:\n                 error_list.append(f\"Field should be maximal {self.max_num}.\")\n         if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(f\"Field should be minimal {self.min_length} characters long.\")\n+            error_list.append(\n+                f\"Field should be minimal {self.min_length} characters long.\"\n+            )\n         if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(f\"Field should be maximal {self.max_length} characters long.\")\n+            error_list.append(\n+                f\"Field should be maximal {self.max_length} characters long.\"\n+            )\n         if self.regex and self.value and not re.match(self.regex, self.value):\n             error_list.append(\"Invalid value.\")\n         if self.kind and not isinstance(self.value, self.kind):\n@@ -141,7 +168,7 @@ class Validator:\n             \"help_text\": self.help_text,\n             \"errors\": errors,\n             \"is_valid\": is_valid,\n-            \"index\": self.index\n+            \"index\": self.index,\n         }\n \n \n@@ -156,7 +183,7 @@ class ModelField(Validator):\n \n     async def to_json(self):\n         result = await super().to_json()\n-        result['name'] = self.name\n+        result[\"name\"] = self.name\n         return result\n \n \n@@ -193,30 +220,39 @@ class UUIDField(ModelField):\n class BaseModel:\n \n     uid = UUIDField(name=\"uid\", required=True)\n-    created_at = CreatedField(name=\"created_at\", required=True, regex=TIMESTAMP_REGEX, place_holder=\"Created at\")\n-    updated_at = UpdatedField(name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\")\n-    deleted_at = DeletedField(name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\")\n-\n-    @classmethod \n+    created_at = CreatedField(\n+        name=\"created_at\",\n+        required=True,\n+        regex=TIMESTAMP_REGEX,\n+        place_holder=\"Created at\",\n+    )\n+    updated_at = UpdatedField(\n+        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n+    )\n+    deleted_at = DeletedField(\n+        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n+    )\n+\n+    @classmethod\n     async def from_record(cls, record, mapper):\n         model = cls.__new__()\n-        model.mapper = mapper \n+        model.mapper = mapper\n         model.record = record\n         return model\n \n-    @property \n+    @property\n     def mapper(self):\n-        return self._mapper \n+        return self._mapper\n \n-    @mapper.setter  \n+    @mapper.setter\n     def mapper(self, value):\n-        self._mapper = value  \n+        self._mapper = value\n \n-    @property \n+    @property\n     def record(self):\n         return {field.name: field.value for field in self.fields}\n-    \n-    @record.setter \n+\n+    @record.setter\n     def record(self, value):\n         for key, value in self._record.items():\n             field = self.fields.get(key)\n@@ -233,10 +269,12 @@ class BaseModel:\n \n             if isinstance(obj, Validator):\n                 self.__dict__[key] = copy.deepcopy(obj)\n-                self.__dict__[key].value = kwargs.pop(key, self.__dict__[key].initial_value)\n+                self.__dict__[key].value = kwargs.pop(\n+                    key, self.__dict__[key].initial_value\n+                )\n                 self.fields[key] = self.__dict__[key]\n                 self.fields[key].model = self\n-                self.fields[key].app = kwargs.get('app')\n+                self.fields[key].app = kwargs.get(\"app\")\n \n     def __setitem__(self, key, value):\n         obj = self.__dict__.get(key)\n@@ -254,16 +292,13 @@ class BaseModel:\n             field = self.fields.get(key)\n             if not field:\n                 continue\n-            if value.get('name'):\n-                value = value.get('value')\n+            if value.get(\"name\"):\n+                value = value.get(\"value\")\n             field.value = value\n \n-    \n-\n     @property\n     async def is_valid(self):\n         return all([await field.is_valid for field in self.fields.values()])\n-        \n \n     def __getitem__(self, key):\n         obj = self.__dict__.get(key)\n@@ -282,20 +317,22 @@ class BaseModel:\n         obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\n-            if not isinstance(value, dict) or not 'value' in value:\n+            if not isinstance(value, dict) or \"value\" not in value:\n                 continue\n             if getattr(self, key).save:\n-                record[key] = value.get('value')\n+                record[key] = value.get(\"value\")\n         return record\n \n     async def to_json(self, encode=False):\n-        model_data = OrderedDict({\n-            \"uid\": self.uid.value,\n-            \"created_at\": self.created_at.value,\n-            \"updated_at\": self.updated_at.value,\n-            \"deleted_at\": self.deleted_at.value,\n-            \"is_valid\": await self.is_valid\n-        })\n+        model_data = OrderedDict(\n+            {\n+                \"uid\": self.uid.value,\n+                \"created_at\": self.created_at.value,\n+                \"updated_at\": self.updated_at.value,\n+                \"deleted_at\": self.deleted_at.value,\n+                \"is_valid\": await self.is_valid,\n+            }\n+        )\n \n         for key, value in self.fields.items():\n             if key == \"record\":\n@@ -325,4 +362,4 @@ class FormElement(ModelField):\n         data = await super().to_json()\n         data[\"name\"] = self.name\n         data[\"place_holder\"] = self.place_holder\n-        return data\n\\ No newline at end of file\n+        return data\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex b319f54..5449c50 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,12 +1,13 @@\n-import hashlib \n+import hashlib\n \n DEFAULT_SALT = b\"snekker-de-snek-\"\n \n-async def hash(data,salt=DEFAULT_SALT):\n+\n+async def hash(data, salt=DEFAULT_SALT):\n     try:\n         data = data.encode(errors=\"ignore\")\n     except AttributeError:\n-        pass \n+        pass\n     try:\n         salt = salt.encode(errors=\"ignore\")\n     except AttributeError:\n@@ -16,5 +17,6 @@ async def hash(data,salt=DEFAULT_SALT):\n     obj = hashlib.sha256(salted)\n     return obj.hexdigest()\n \n-async def verify(string:str, hashed:str):\n-    return await hash(string) == hashed \n+\n+async def verify(string: str, hashed: str):\n+    return await hash(string) == hashed\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d970b5f..1f9d601 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,24 +1,22 @@\n-\n-\n-\n from snek.mapper import get_mapper\n-from snek.system.mapper import BaseMapper \n from snek.model.user import UserModel\n+from snek.system.mapper import BaseMapper\n+\n \n class BaseService:\n \n-    mapper_name:BaseMapper = None\n+    mapper_name: BaseMapper = None\n \n     def __init__(self, app):\n-        self.app = app \n+        self.app = app\n         if self.mapper_name:\n             self.mapper = get_mapper(self.mapper_name, app=self.app)\n         else:\n-            self.mapper = None \n+            self.mapper = None\n \n     async def exists(self, **kwargs):\n         return await self.count(**kwargs) > 0\n-    \n+\n     async def count(self, **kwargs):\n         return await self.mapper.count(**kwargs)\n \n@@ -27,14 +25,13 @@ class BaseService:\n \n     async def get(self, **kwargs):\n         return await self.mapper.get(**kwargs)\n-    \n-    async def save(self, model:UserModel):\n+\n+    async def save(self, model: UserModel):\n-        return await self.mapper.save(model) and True \n-         \n-    \n+        return await self.mapper.save(model) and True\n+\n     async def find(self, **kwargs):\n         return await self.mapper.find(**kwargs)\n-    \n+\n     async def delete(self, **kwargs):\n-        return await self.mapper.delete(**kwargs)\n\\ No newline at end of file\n+        return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 3b53f33..1cf5329 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,13 +1,14 @@\n from aiohttp import web\n \n-from snek.system.markdown import render_markdown \n+from snek.system.markdown import render_markdown\n+\n \n class BaseView(web.View):\n-    \n-    @property \n+\n+    @property\n     def app(self):\n         return self.request.app\n-    \n+\n     @property\n     def db(self):\n         return self.app.db\n@@ -17,31 +18,36 @@ class BaseView(web.View):\n \n     async def render_template(self, template_name, context=None):\n         if template_name.endswith(\".md\"):\n-            response = await self.request.app.render_template(template_name,self.request,context)\n+            response = await self.request.app.render_template(\n+                template_name, self.request, context\n+            )\n             body = await render_markdown(self.app, response.body.decode())\n-            return web.Response(body=body,content_type=\"text/html\")\n-        return await self.request.app.render_template(template_name, self.request,context)\n-    \n+            return web.Response(body=body, content_type=\"text/html\")\n+        return await self.request.app.render_template(\n+            template_name, self.request, context\n+        )\n+\n+\n class BaseFormView(BaseView):\n \n-    form = None \n+    form = None\n \n     async def get(self):\n         form = self.form(app=self.app)\n-        \n+\n         return await self.json_response(await form.to_json())\n-       \n+\n     async def post(self):\n         form = self.form(app=self.app)\n         post = await self.request.json()\n-        form.set_user_data(post['form'])\n+        form.set_user_data(post[\"form\"])\n         result = await form.to_json()\n-        if post.get('action') == 'validate':\n+        if post.get(\"action\") == \"validate\":\n             pass\n-        if post.get('action') == 'submit' and result['is_valid']:\n+        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n             await self.submit(form)\n-        return await self.json_response(result) \n+        return await self.json_response(result)\n \n-    async def submit(self,model=None):\n+    async def submit(self, model=None):\n         print(\"Submit sucess\")\ndiff --git a/src/snek/templates/docs.md b/src/snek/templates/docs.md\nindex 2cf60cb..a42b7d5 100644\n--- a/src/snek/templates/docs.md\n+++ b/src/snek/templates/docs.md\n@@ -9,8 +9,7 @@ Currently only some details about the internal API are available.\n \n new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n+    username=\"retoor\", password=\"retoorded\"\n )\n ```\n \n@@ -23,15 +22,16 @@ var1 = security.encrypt(\"data\")\n var2 = security.encrypt(b\"data\")\n \n-assert(var1 == var2)\n+assert var1 == var2\n ```\n \n ```python\n-from snek.system.view import BaseView \n+from snek.system.view import BaseView\n+\n \n class IndexView(BaseView):\n-    \n+\n     async def get(self):\n@@ -40,11 +40,12 @@ class IndexView(BaseView):\n ```\n ```python\n-from snek.system.view import BaseFormView\n from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView\n+\n \n class RegisterFormView(BaseFormView):\n-    \n+\n     form = RegisterForm\n ```\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 593d5a9..762fc8e 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,5 +1,3 @@\n-\n-\n from snek.system.view import BaseView\n \n \n@@ -7,8 +5,9 @@ class AboutHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"about.html\")\n-    \n+\n+\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex e69d754..519a0eb 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,6 +1,3 @@\n-\n-\n-\n from snek.system.view import BaseView\n \n \n@@ -8,8 +5,9 @@ class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n-    \n+\n+\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex c7861fa..bd91dc8 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,5 +1,6 @@\n from snek.system.view import BaseView\n \n+\n class IndexView(BaseView):\n \n     async def get(self):\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex ffedc79..6566df9 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,13 +1,18 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseView\n \n+\n class LoginView(BaseView):\n \n     async def get(self):\n-        \n+        return await self.render_template(\n+            \"login.html\"\n+\n     async def post(self):\n         form = RegisterForm()\n         form.set_user_data(await self.request.post())\n         print(form.is_valid())\n+        return await self.render_template(\n+            \"login.html\", self.request\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex e9b6eac..576ddc6 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,5 +1,6 @@\n-from snek.system.view import BaseFormView\n from snek.form.login import LoginForm\n+from snek.system.view import BaseFormView\n+\n \n class LoginFormView(BaseFormView):\n-    form = LoginForm\n\\ No newline at end of file\n+    form = LoginForm\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex e3b3038..1186959 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,6 +1,7 @@\n from snek.system.view import BaseView\n \n+\n class RegisterView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"register.html\")  \n\\ No newline at end of file\n+        return await self.render_template(\"register.html\")\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 8cb6567..4c30169 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,9 +1,12 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n     async def submit(self, form):\n-        result = await self.app.services.user.register(form.email.value,form.username.value,form.password.value)\n-        print(\"SUBMITTED:\",result)\n\\ No newline at end of file\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        print(\"SUBMITTED:\", result)\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex b06563a..d42fcec 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,6 +1,7 @@\n from snek.system.view import BaseView\n \n+\n class WebView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"web.html\")\n\\ No newline at end of file\n+        return await self.render_template(\"web.html\")"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added initial documentation for the Snek project.", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b", "diff": "commit b56371994f5d3f5c1aa5d63c28efd18856ea8e9b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:41:54 2025 +0100\n\n    Added docs.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nnew file mode 100644\nindex 0000000..087bb64\nBinary files /dev/null and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nnew file mode 100644\nindex 0000000..5e00d98\n--- /dev/null\n+++ b/src/snek/docs/app.py\n@@ -0,0 +1,33 @@\n+from app.app import Application as BaseApplication\n+import pathlib \n+from aiohttp import web\n+from snek.system.markdown import MarkdownExtension\n+\n+from snek.system.markdown import render_markdown \n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, path=None, *args,**kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path ,*args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension) \n+        \n+        self.router.add_get(\"/{tail:.*}\",self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info['tail'].strip(\"/\")\n+        if relative_path == '':\n+            relative_path = 'index.html'\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+        \n+        response = await self.render_template(str(document_path.relative_to(self.path)),request)\n+        return response\n+\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\nnew file mode 100644\nindex 0000000..e30a99d\n--- /dev/null\n+++ b/src/snek/docs/docs/api.html\n@@ -0,0 +1,61 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\nnew file mode 100644\nindex 0000000..7c53bec\n--- /dev/null\n+++ b/src/snek/docs/docs/base.html\n@@ -0,0 +1,116 @@\n+<html>\n+\n+<head>\n+    <style>{{ highlight_styles }}</style>\n+    <style>\n+        \n+        * {\n+\n+            box-sizing: border-box;\n+        }\n+\n+        .dialog {\n+\n+            border-radius: 10px;\n+            padding: 30px;\n+            width: 800px;\n+            margin: 30px;\n+            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        }\n+\n+        @media screen and (max-width: 500px) {\n+            .center {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+            .dialog {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+        }\n+\n+        h1 {\n+            font-size: 2em;\n+            margin-bottom: 20px;\n+        }\n+\n+        h2 {\n+            font-size: 1.4em;\n+            margin-bottom: 20px;\n+        }\n+\n+        html,body,main {\n+            font-family: Arial, sans-serif;\n+            line-height: 1.5;\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            min-height: 100vh;\n+            width: 100%;\n+        }\n+        article {\n+            max-width: 100%;\n+            width: 60%;\n+            padding: 30px;\n+            min-height: 100vh;\n+            word-break: break-all;\n+        }\n+        footer {\n+            position: fixed;\n+            width: 60%;\n+            text-align: center; \n+            bottom: 0;\n+            left: 20%;\n+        }\n+        a {\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  header {\n+\n+    text-align: left;\n+    width: 60%;\n+    padding: 30px;\n+  }\n+  header a {\n+    display: inline;\n+\n+  }\n+        div {\n+            text-align: left;\n+\n+        }\n+    </style>\n+</head>\n+\n+<body>\n+    <main>\n+        <header>\n+            <a href=\"/\">Snek</a>\n+            <a href=\"/docs/docs\">Docs</a>\n+        </header>\n+      <article>\n+            {% block main %}\n+            {% endblock %}\n+        </article>\n+    </main>\n+    <footer>\n+        {% markdown %}\n+        {% endmarkdown %}\n+    </footer>\n+</body>\n+\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\nnew file mode 100644\nindex 0000000..c83ee7f\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_javascript.html\n@@ -0,0 +1,17 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - generic-form.js \n+\n+It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n+```html \n+<generic-form url=\"/url-to-form-api\"></generic-form>\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\nnew file mode 100644\nindex 0000000..d7e67bc\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_python.html\n@@ -0,0 +1,92 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - `snek.system.form.Form`\n+ - `snek.system.form.HTMLElement`\n+ - `snek.system.form.FormInputElement`\n+ - `snek.system.form.FormButtonElement`\n+\n+Here is an example with custom validation. \n+This example contains a field that checks if user already exists. \n+If invalid, it adds an error message which automatically invalidates the field. \n+Handling of the error messages will automatically done client side.\n+\n+Forms are usaly located in `snek/form/[form name].py`.\n+\n+```python\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\"\n+    )\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+```\n+\n+\n+```python \n+data = dict(\n+    username=dict(value=\"retoor\"),\n+    password=dict(value=\"retoorded\")\n+)\n+form.set_user_data(data)\n+\n+is_valid = await form.is_valid\n+\n+key_value_values = await form.record \n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\nnew file mode 100644\nindex 0000000..8ced8ab\n--- /dev/null\n+++ b/src/snek/docs/docs/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Snek is a high customizable chat application. \n+It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n+\n+This documentation is under construction. Only the form API and the small introduction is a bit documented.\n+\n+[Small introduction / cheatsheet](/docs/docs/api.html)\n+\n+With the view classes of Snek you can render HTML and Markdown \n+\n+Snek's database model is based on Python dataset library. \n+Snek uses a model/mapper architecture build on top of that library.\n+\n+Snek does have his own components for creating and rendering forms. \n+All forms are made server side and client side is generated client side using a HTML component.\n+It's client side only one line to include a form that can validate and submit.\n+Validation is server side using REST. Page won't refresh.\n+[API Python](/docs/docs/form_api_python.html) \n+[API Javascript](/docs/docs/form_api_javascript.html)\n+\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559", "diff": "commit dae877113c76b6f0eded7b2d63ef921123a2b559\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 24 23:42:24 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 5e00d98..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,33 +1,43 @@\n-from app.app import Application as BaseApplication\n-import pathlib \n+import pathlib\n+\n from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n \n-from snek.system.markdown import render_markdown \n \n class Application(BaseApplication):\n \n-    def __init__(self, path=None, *args,**kwargs):\n+    def __init__(self, path=None, *args, **kwargs):\n         self.path = pathlib.Path(path)\n         template_path = self.path\n \n-        super().__init__(template_path=template_path ,*args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension) \n-        \n-        self.router.add_get(\"/{tail:.*}\",self.handle_document)\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n \n     async def handle_document(self, request):\n-        relative_path = request.match_info['tail'].strip(\"/\")\n-        if relative_path == '':\n-            relative_path = 'index.html'\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n         document_path = self.path.joinpath(relative_path)\n         if not document_path.exists():\n-            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n         if document_path.is_dir():\n             document_path = document_path.joinpath(\"index.html\")\n         if not document_path.exists():\n-            return web.Response(status=404,body=b'Resource is not found on this server.',content_type=\"text/plain\")\n-        \n-        response = await self.render_template(str(document_path.relative_to(self.path)),request)\n-        return response\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n \n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41", "diff": "commit 5c69e14d7cfae8da4efab776165cc8e466edcc41\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:46:33 2025 +0100\n\n    Added session support.\n\ndiff --git a/cache/crc321300331366.cache b/cache/crc321300331366.cache\ndeleted file mode 100644\nindex e69de29..0000000\ndiff --git a/cache/crc322507170282.cache b/cache/crc322507170282.cache\ndeleted file mode 100644\nindex e69de29..0000000\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cc36846..71e90a7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -21,7 +21,8 @@ dependencies = [\n     \"gunicorn\",\n     \"imgkit\",\n     \"wkhtmltopdf\",\n-    \"jinja-markdown2\",\n-    \"mistune\"\n+    \"mistune\",\n+    \"aiohttp-session\",\n+    \"cryptography\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ab19f42..0abf24b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,23 @@ from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n+from snek.view.status import StatusView\n from snek.view.web import WebView\n \n+from aiohttp import web\n+from aiohttp_session import setup as session_setup, get_session as session_get, session_middleware\n+from aiohttp_session.cookie_storage import EncryptedCookieStorage\n+import base64\n+\n+SESSION_KEY = b'c79a0c5fda4b424189c427d28c9f7c34'\n+\n+@web.middleware\n+async def session_middleware(request, handler):\n+    setattr(request,\"session\", await session_get(request))\n+    response = await handler(request)\n+    return response\n+\n \n class Application(BaseApplication):\n \n@@ -31,10 +46,13 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n+        session_setup(self,EncryptedCookieStorage(SESSION_KEY))\n+        self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n         self.setup_services()\n \n+    \n     def setup_services(self):\n         self.services = SimpleNamespace(**get_services(app=self))\n         self.mappers = SimpleNamespace(**get_mappers(app=self))\n@@ -51,7 +69,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-\n+        self.router.add_view(\"/status.json\",StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nindex 087bb64..6f355a6 100644\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f06ee24..d8d3a8f 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -67,7 +67,7 @@ class Room {\n class InlineAppElement extends HTMLElement {\n     \n     constructor(){\n-        this.\n     }\n \n }\n@@ -77,6 +77,45 @@ class Page {\n \n }\n \n+class RESTClient {\n+    debug = true \n+    \n+    async get(url, params){\n+        params = params ? params : {} \n+        const encodedParams = new URLSearchParams(params);\n+        if(encodedParams)\n+            url += '?' + encodedParams\n+        const response = await fetch(url,{\n+            method: 'GET',\n+            headers: {\n+              'Content-Type': 'application/json'\n+            }\n+          });\n+          const result =  await response.json()\n+          if(this.debug){\n+            console.debug({url:url,params:params,result:result})\n+          }\n+        return result \n+    }\n+    async post(url, data) {\n+    const response = await fetch(url,{\n+        method: 'POST',\n+        headers: {\n+          'Content-Type': 'application/json'\n+        },\n+        body: JSON.stringify(data)\n+      });\n+\n+      const result =  await response.json()\n+          if(this.debug){\n+            console.debug({url:url,params:params,result:result})\n+          }\n+        return result\n+    }\n+}\n+\n+const rest = new RESTClient()\n+\n class App {\n     rooms = []\n     constructor() {\n@@ -84,7 +123,9 @@ class App {\n \n \n     }\n+    async post(url, data){\n \n+    }\n \n \n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex a47c6db..e775bf4 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -282,8 +282,10 @@ class GenericForm extends HTMLElement {\n               {\n                 const isValid = await me.validate()\n                 if(isValid){\n-                  const isProcessed = await me.submit()\n-                  console.info({processed:isProcessed})\n+                  const saveResult = await me.submit()\n+                  if(saveResult.redirect_url){\n+                    window.location.pathname = saveResult.redirect_url\n+                  }\n                 }\n               }\n             }\n@@ -315,7 +317,6 @@ class GenericForm extends HTMLElement {\n         if(!field.is_valid){\n           me.fields[field.name].setInvalid()\n           me.fields[field.name].setErrors(field.errors)\n-          console.info(field.name,\"is invalid\")\n         }else{\n           me.fields[field.name].setValid()\n         }\n@@ -323,10 +324,8 @@ class GenericForm extends HTMLElement {\n         me.fields[field.name].updateAttributes()\n       })\n       Object.values(form.fields).forEach(field=>{\n-        console.info(field.errors)\n         me.fields[field.name].setErrors(field.errors)\n       })\n-      console.info({XX:form})\n       return form['is_valid']\n     }\n     async submit(){\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex b41f4ba..98729b2 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -212,6 +212,14 @@ class DeletedField(ModelField):\n \n class UUIDField(ModelField):\n \n+    @property\n+    def value(self):\n+        return str(self._value)\n+    \n+    @value.setter\n+    def value(self,val):\n+         self._value = str(val)\n+\n     @property\n     def initial_value(self):\n         return str(uuid.uuid4())\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 1cf5329..8eebcb0 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -5,6 +5,13 @@ from snek.system.markdown import render_markdown\n \n class BaseView(web.View):\n \n+    login_required = False\n+\n+    async def _iter(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        return await super()._iter()\n+\n     @property\n     def app(self):\n         return self.request.app\n@@ -16,6 +23,10 @@ class BaseView(web.View):\n     async def json_response(self, data):\n         return web.json_response(data)\n \n+    @property \n+    def session(self):\n+        return self.request.session\n+\n     async def render_template(self, template_name, context=None):\n         if template_name.endswith(\".md\"):\n             response = await self.request.app.render_template(\n@@ -46,7 +57,8 @@ class BaseFormView(BaseView):\n             pass\n         if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n-            await self.submit(form)\n+            result = await self.submit(form)\n+            return await self.json_response(result)\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex e00c886..cb8fc5d 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n+  <script src=\"/app.js\"></script>\n   <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0403e1b..5a636f0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -3,12 +3,13 @@\n <head>\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Dark Themed Chat Application</title>\n+  <title>Snek</title>\n+  <script src=\"/app.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n   <header>\n-    <div class=\"logo\">Molodetz Chat</div>\n+    <div class=\"logo\">Snek</div>\n     <nav>\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 4c30169..761c7c1 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -9,4 +9,8 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        print(\"SUBMITTED:\", result)\n+        self.request.session[\"uid\"] = result['uid']\n+        self.request.session[\"username\"] = result['usernmae']\n+        self.request.session[\"logged_in\"] = True\n+\n+        return dict(redirect_url=\"/web.html\")\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex d42fcec..63bacab 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -3,5 +3,7 @@ from snek.system.view import BaseView\n \n class WebView(BaseView):\n \n+    login_required = True\n+\n     async def get(self):\n         return await self.render_template(\"web.html\")"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16", "diff": "commit 352d2deb12a471bc90425961849fb2e92da3ab16\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:46:53 2025 +0100\n\n    Added status\n\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nnew file mode 100644\nindex 0000000..61e2c6c\n--- /dev/null\n+++ b/src/snek/view/status.py\n@@ -0,0 +1,9 @@\n+\n+\n+\n+from snek.system.view import BaseView\n+\n+\n+class StatusView(BaseView):\n+    async def get(self):\n+        return await self.json_response({\"status\": \"ok\", \"username\": self.session.get(\"username\"),\"logged_in\":self.session.get(\"username\") and True or False, \"uid\":self.session.get(\"uid\")})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Integrate aiohttp-session for user sessions and improve session handling", "commit": "12ca8e4296ca9693276422e524d7061685556ba0", "diff": "commit 12ca8e4296ca9693276422e524d7061685556ba0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 03:47:16 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0abf24b..584b321 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,12 @@ import pathlib\n from types import SimpleNamespace\n \n from aiohttp import web\n+from aiohttp_session import (\n+    get_session as session_get,\n+    session_middleware,\n+    setup as session_setup,\n+)\n+from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n \n from snek.docs.app import Application as DocsApplication\n@@ -20,17 +26,13 @@ from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \n-from aiohttp import web\n-from aiohttp_session import setup as session_setup, get_session as session_get, session_middleware\n-from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-import base64\n+SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n-SESSION_KEY = b'c79a0c5fda4b424189c427d28c9f7c34'\n \n @web.middleware\n async def session_middleware(request, handler):\n-    setattr(request,\"session\", await session_get(request))\n+    setattr(request, \"session\", await session_get(request))\n     response = await handler(request)\n     return response\n \n@@ -46,13 +48,12 @@ class Application(BaseApplication):\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n-        session_setup(self,EncryptedCookieStorage(SESSION_KEY))\n+        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n         self.setup_services()\n \n-    \n     def setup_services(self):\n         self.services = SimpleNamespace(**get_services(app=self))\n         self.mappers = SimpleNamespace(**get_mappers(app=self))\n@@ -69,7 +70,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n-        self.router.add_view(\"/status.json\",StatusView)\n+        self.router.add_view(\"/status.json\", StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginFormView)\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 98729b2..7efde64 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -215,10 +215,10 @@ class UUIDField(ModelField):\n     @property\n     def value(self):\n         return str(self._value)\n-    \n+\n     @value.setter\n-    def value(self,val):\n-         self._value = str(val)\n+    def value(self, val):\n+        self._value = str(val)\n \n     @property\n     def initial_value(self):\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8eebcb0..1074615 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -23,7 +23,7 @@ class BaseView(web.View):\n     async def json_response(self, data):\n         return web.json_response(data)\n \n-    @property \n+    @property\n     def session(self):\n         return self.request.session\n \ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 761c7c1..89fa3ac 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -9,8 +9,8 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result['uid']\n-        self.request.session[\"username\"] = result['usernmae']\n+        self.request.session[\"uid\"] = result[\"uid\"]\n+        self.request.session[\"username\"] = result[\"usernmae\"]\n         self.request.session[\"logged_in\"] = True\n \n-        return dict(redirect_url=\"/web.html\")\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 61e2c6c..add86a6 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,9 +1,13 @@\n-\n-\n-\n from snek.system.view import BaseView\n \n \n class StatusView(BaseView):\n     async def get(self):\n-        return await self.json_response({\"status\": \"ok\", \"username\": self.session.get(\"username\"),\"logged_in\":self.session.get(\"username\") and True or False, \"uid\":self.session.get(\"uid\")})\n\\ No newline at end of file\n+        return await self.json_response(\n+            {\n+                \"status\": \"ok\",\n+                \"username\": self.session.get(\"username\"),\n+                \"logged_in\": self.session.get(\"username\") and True or False,\n+                \"uid\": self.session.get(\"uid\"),\n+            }\n+        )"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7", "diff": "commit bb6bcf41d1bb2132684b6251853f7d34e202a9f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 05:50:23 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 584b321..f26fc06 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -21,6 +21,7 @@ from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.login_form import LoginFormView\n+from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n@@ -68,6 +69,8 @@ class Application(BaseApplication):\n         )\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/logout.json\", LogoutView)\n+        self.router.add_view(\"/logout.html\", LogoutView)\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 3d6d9a7..2966053 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,11 +1,24 @@\n from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n \n \n+class AuthField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.model.password.value and self.model.username.value:\n+            if not await self.app.services.user.validate_login(\n+                self.model.username.value, self.model.password.value\n+            ):\n+                return [\"Invalid username or password\"]\n+        return result\n+\n+\n class LoginForm(Form):\n \n     title = HTMLElement(tag=\"h1\", text=\"Login\")\n \n-    username = FormInputElement(\n+    username = AuthField(\n         name=\"username\",\n         required=True,\n         min_length=2,\n@@ -14,7 +27,7 @@ class LoginForm(Form):\n         place_holder=\"Username\",\n         type=\"text\",\n     )\n-    password = FormInputElement(\n+    password = AuthField(\n         name=\"password\",\n         required=True,\n         regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n@@ -25,3 +38,14 @@ class LoginForm(Form):\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n     )\n+\n+    @property\n+    async def is_valid(self):\n+        return all(\n+            [\n+                self[\"username\"],\n+                self[\"password\"],\n+                not await self.username.errors,\n+                not await self.password.errors,\n+            ]\n+        )\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 5124640..cfcd6b8 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -5,13 +5,23 @@ from snek.system.service import BaseService\n class UserService(BaseService):\n     mapper_name = \"user\"\n \n+    async def validate_login(self, username, password):\n+        model = await self.get(username=username)\n+        print(\"FOUND USER!\", model, flush=True)\n+        if not model:\n+            return False\n+        print(\"AU\", password, model.password.value, flush=True)\n+        if not await security.verify(password, model[\"password\"]):\n+            return False\n+        return True\n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n-        model.email = email\n-        model.username = username\n-        model.password = await security.hash(password)\n+        model.email.value = email\n+        model.username.value = username\n+        model.password.value = await security.hash(password)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex d8d3a8f..133cbcd 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -113,9 +113,98 @@ class RESTClient {\n         return result\n     }\n }\n-\n const rest = new RESTClient()\n \n+class EventHandler {\n+\n+    constructor(){\n+        this.subscribers = {}\n+    }\n+    addEventListener(type,handler){\n+        if(!this.subscribers[type])\n+            this.subscribers[type] = []\n+        this.subscribers[type].push(handler)\n+    }\n+    emit(type,...data){\n+        if(this.subscribers[type])\n+            this.subscribers[type].forEach(handler=>handler(...data))\n+    }\n+\n+}\n+\n+class Chat extends EventHandler {\n+\n+    constructor() {\n+        super()\n+        this._socket = null \n+        this._wait_connect = null \n+        this._promises = {}\n+    }\n+    connect(){\n+        if(this._wait_connect)\n+            return this._wait_connect\n+        \n+            const me = this \n+            return new Promise(async (resolve,reject)=>{\n+                me._wait_connect = resolve \n+                me._socket = new WebSocket(me._url)\n+                console.debug(\"Connecting..\")\n+                \n+                me._socket.onconnect = ()=>{\n+                    me._connected()\n+                    me._wait_socket(me)\n+                }\n+            }) \n+        \n+    }\n+    generateUniqueId() {\n+    }\n+    call(method,...args){\n+        const me = this \n+        return new Promise(async (resolve,reject)=>{\n+            try{\n+                const command = {method:method,args:args,message_id:me.generateUniqueId()}\n+                me._promises[command.message_id] = resolve\n+                await me._socket.send(JSON.stringify(command)) \n+               \n+            }catch(e){\n+                reject(e)\n+            }\n+        })\n+    }\n+    _connected() {\n+        const me = this \n+        this._socket.onmessage = (event) => {\n+            const message = JSON.parse(event.data)\n+            if(message.message_id && me._promises[message.message_id]){\n+                me._promises[message.message_id](message)\n+                delete me._promises[message.message_id]\n+            }else{\n+            me.emit(\"message\",me, message)\n+            }\n+        }\n+        this._socket.onclose = (event) => {\n+            me._wait_socket = null \n+            me._socket = null \n+            me.emit('close',me)\n+        }\n+    }\n+\n+    async privmsg(room, text) {\n+        await rest.post(\"/api/privmsg\",{\n+            room:room,\n+            text:text\n+        })\n+    }\n+\n+}\n+\n+\n class App {\n     rooms = []\n     constructor() {\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex f6c6200..489ff90 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -29,8 +29,14 @@ class BaseMapper:\n     async def get(self, uid: str = None, **kwargs) -> BaseModel:\n         if uid:\n             kwargs[\"uid\"] = uid\n-        self.new()\n         record = self.table.find_one(**kwargs)\n+        if not record:\n+            return None\n+        record = dict(record)\n+        model = await self.new()\n+        for key, value in record.items():\n+            model[key] = value\n+        return model\n         return await self.model_class.from_record(mapper=self, record=record)\n \n     async def exists(self, **kwargs):\n@@ -40,10 +46,9 @@ class BaseMapper:\n         return self.table.count(**kwargs)\n \n     async def save(self, model: BaseModel) -> bool:\n-        record = await model.record\n-        if not record.get(\"uid\"):\n-            raise Exception(f\"Attempt to save without uid: {record}.\")\n-        return self.table.upsert(record, [\"uid\"])\n+        if not model.record.get(\"uid\"):\n+            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\n         if not kwargs.get(\"_limit\"):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 7efde64..ba3fc45 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -243,7 +243,7 @@ class BaseModel:\n \n     @classmethod\n     async def from_record(cls, record, mapper):\n-        model = cls.__new__()\n+        model = cls()\n         model.mapper = mapper\n         model.record = record\n         return model\n@@ -258,15 +258,15 @@ class BaseModel:\n \n     @property\n     def record(self):\n-        return {field.name: field.value for field in self.fields}\n+        return {key: field.value for key, field in self.fields.items()}\n \n     @record.setter\n-    def record(self, value):\n-        for key, value in self._record.items():\n+    def record(self, val):\n+        for key, value in val.items():\n             field = self.fields.get(key)\n             if not field:\n                 continue\n-            field.value = value\n+            self[key] = value\n         return self\n \n     def __init__(self, *args, **kwargs):\n@@ -321,7 +321,7 @@ class BaseModel:\n             self.__dict__[key] = value\n \n     @property\n-    async def record(self):\n+    async def recordz(self):\n         obj = await self.to_json()\n         record = {}\n         for key, value in obj.items():\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 5a636f0..ae0bb06 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,10 +11,10 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n+      <a href=\"/web.html\">Home</a>\n+      <a href=\"/logout.html\">Logout</a>\n     </nav>\n   </header>\n   <main>\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 576ddc6..f0efce9 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -4,3 +4,11 @@ from snek.system.view import BaseFormView\n \n class LoginFormView(BaseFormView):\n     form = LoginForm\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nnew file mode 100644\nindex 0000000..eb5c1ae\n--- /dev/null\n+++ b/src/snek/view/logout.py\n@@ -0,0 +1,27 @@\n+from aiohttp import web\n+\n+from snek.system.view import BaseView\n+\n+\n+class LogoutView(BaseView):\n+\n+    redirect_url = \"/\"\n+    login_required = True\n+\n+    async def get(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return web.HTTPFound(self.redirect_url)\n+\n+    async def post(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return await self.json_response({\"redirect_url\": self.redirect_url})"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and model setup, add channel and member models\n\nThis commit introduces a more structured approach to service and model initialization, enhancing code organization and maintainability.\n\nChanges:\n\n-   Introduced `Object` class for managing service and model collections.\n-   Added `Channel`, `ChannelMember`, and `ChannelMessage` models.\n-   Created corresponding `Channel` and `ChannelMember` services.\n-   Updated `app.py` to utilize the new services and models.\n-   Modified `mapper.py` to use `SimpleNamespace` for mapper collections.\n-   Refactored `app.py` to simplify service setup.\n-   Added `Cache` class to improve performance.\n-   Updated `login.py` and `register.py` to use BaseFormView.", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8", "diff": "commit b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 22:24:44 2025 +0100\n\n    Mappers and models.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f26fc06..bc0884a 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -14,6 +14,7 @@ from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n+from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -53,11 +54,9 @@ class Application(BaseApplication):\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.setup_router()\n-        self.setup_services()\n-\n-    def setup_services(self):\n-        self.services = SimpleNamespace(**get_services(app=self))\n-        self.mappers = SimpleNamespace(**get_mappers(app=self))\n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n@@ -76,9 +75,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/status.json\", StatusView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login.json\", LoginFormView)\n+        self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n-        self.router.add_view(\"/register.json\", RegisterFormView)\n+        self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 2b9b79f..1f29d73 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,11 +1,21 @@\n import functools\n+from types import SimpleNamespace\n \n+from snek.mapper.channel import ChannelMapper\n+from snek.mapper.channel_member import ChannelMemberMapper\n+from snek.mapper.channel_message import ChannelMessageMapper\n from snek.mapper.user import UserMapper\n+from snek.system.object import Object\n \n \n @functools.cache\n def get_mappers(app=None):\n-    return {\"user\": UserMapper(app=app)}\n+    return Object(\n+        **{\"user\": UserMapper(app=app),\n+            'channel_member': ChannelMemberMapper(app=app),\n+            'channel': ChannelMapper(app=app),\n+            'channel_message': ChannelMessageMapper(app=app)    \n+    })\n \n \n def get_mapper(name, app=None):\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nnew file mode 100644\nindex 0000000..6239dc8\n--- /dev/null\n+++ b/src/snek/mapper/channel.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel import ChannelModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMapper(BaseMapper):\n+    table_name = \"channel\"\n+    model_class = ChannelModel\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nnew file mode 100644\nindex 0000000..f0f62d6\n--- /dev/null\n+++ b/src/snek/mapper/channel_member.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_member import ChannelMemberModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMemberMapper(BaseMapper):\n+    table_name = \"channel_member\"\n+    model_class = ChannelMemberModel\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nnew file mode 100644\nindex 0000000..364c1ee\n--- /dev/null\n+++ b/src/snek/mapper/channel_message.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_message import ChannelMessageModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelMessageMapper(BaseMapper):\n+    model_class = ChannelMessageModel\n+    table_name = \"channel_message\"\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 081ae15..ccb5289 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,11 +1,19 @@\n import functools\n \n+from snek.model.channel import ChannelModel\n+from snek.model.channel_member import ChannelMemberModel\n+from snek.model.channel_message import ChannelMessageModel\n from snek.model.user import UserModel\n+from snek.system.object import Object\n \n \n @functools.cache\n def get_models():\n-    return {\"user\": UserModel}\n+    return Object(**{\"user\": UserModel,\n+            \"channel_member\": ChannelMemberModel,\n+            \"channel\": ChannelModel,\n+            \"channel_message\": ChannelMessageModel})\n \n \n def get_model(name):\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nnew file mode 100644\nindex 0000000..50b1181\n--- /dev/null\n+++ b/src/snek/model/channel.py\n@@ -0,0 +1,11 @@\n+from snek.system.model import BaseModel, ModelField\n+\n+class ChannelModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True,kind=str)\n+    description = ModelField(name=\"description\", required=False,kind=str)\n+    tag = ModelField(name=\"tag\", required=False,kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True,kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True,kind=bool,value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True,kind=bool,value=True)\n+    index = ModelField(name=\"index\", required=True,kind=int,value=1000) \n+\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nnew file mode 100644\nindex 0000000..48131e4\n--- /dev/null\n+++ b/src/snek/model/channel_member.py\n@@ -0,0 +1,10 @@\n+from snek.system.model import BaseModel, ModelField\n+\n+class ChannelMemberModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True,kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n+    is_moderator = ModelField(name=\"is_moderator\", required=True,kind=bool,value=False)\n+    is_read_only = ModelField(name=\"is_read_only\", required=True,kind=bool,value=False)\n+    is_muted = ModelField(name=\"is_muted\", required=True,kind=bool,value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True,kind=bool,value=False)\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nnew file mode 100644\nindex 0000000..9e96307\n--- /dev/null\n+++ b/src/snek/model/channel_message.py\n@@ -0,0 +1,9 @@\n+\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class ChannelMessageModel(BaseModel):\n+    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n+    message = ModelField(name=\"message\", required=True,kind=str)\n\\ No newline at end of file\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nnew file mode 100644\nindex 0000000..0a5c294\n--- /dev/null\n+++ b/src/snek/model/notification.py\n@@ -0,0 +1,12 @@\n+\n+\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class NotificationModel(BaseModel):\n+    object_uid = ModelField(name=\"object_uid\", required=True)\n+    object_type = ModelField(name=\"object_type\", required=True)\n+    message = ModelField(name=\"message\", required=True)\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    read_at = ModelField(name=\"is_read\", required=True)\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex adb236b..97070c4 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -10,6 +10,13 @@ class UserModel(BaseModel):\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n     )\n+    nick = ModelField(\n+        name=\"nick\",\n+        required=False,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+    )\n     email = ModelField(\n         name=\"email\",\n         required=False,\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 60fec76..8457917 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,12 +1,21 @@\n import functools\n \n+from snek.service.channel import ChannelService\n from snek.service.user import UserService\n+from snek.service.channel_member import ChannelMemberService\n+from types import SimpleNamespace\n \n+from snek.system.object import Object\n \n @functools.cache\n def get_services(app):\n-\n-    return {\"user\": UserService(app=app)}\n+    return Object(\n+        **{\n+            \"user\": UserService(app=app),\n+            \"channel_member\": ChannelMemberService(app=app),\n+            'channel': ChannelService(app=app)\n+        }\n+)\n \n \n def get_service(name, app=None):\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nnew file mode 100644\nindex 0000000..9290baf\n--- /dev/null\n+++ b/src/snek/service/channel.py\n@@ -0,0 +1,31 @@\n+from snek.system.service import BaseService\n+\n+class ChannelService(BaseService):\n+    mapper_name = \"channel\"\n+\n+    async def create(self, label, created_by_uid,  description=None, tag=None, is_private=False, is_listed=True):\n+        count = await self.count(deleted_at=None)\n+        if not tag and not count:\n+            tag = \"public\"\n+        model = await self.new()\n+        model['label'] = label\n+        model['description'] = description\n+        model['tag'] = tag \n+        model['created_by_uid'] = created_by_uid\n+        model['is_private'] = is_private\n+        model['is_listed'] = is_listed\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel: {model.errors}.\")\n+    \n+    async def ensure_public_channel(self, created_by_uid):\n+        model = await self.get(is_listed=True,tag=\"public\")\n+        is_moderator = False \n+        if not model:\n+            is_moderator = True \n+            model = await self.create(\"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\")\n+        await self.app.services.channel_member.create(model['uid'], created_by_uid, is_moderator=is_moderator, is_read_only=False, is_muted=False, is_banned=False)\n+        return model\n+    \n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nnew file mode 100644\nindex 0000000..18d4842\n--- /dev/null\n+++ b/src/snek/service/channel_member.py\n@@ -0,0 +1,24 @@\n+from snek.system.service import BaseService \n+\n+class ChannelMemberService(BaseService):\n+\n+    mapper_name = \"channel_member\"\n+\n+    async def create(self, channel_uid, user_uid, is_moderator=False, is_read_only=False, is_muted=False, is_banned=False):\n+        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        if model:\n+            if model.is_banned.value:\n+                return False \n+            return model\n+        model = await self.new()\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        model['label'] = channel['label']\n+        model['channel_uid'] = channel_uid\n+        model['user_uid'] = user_uid\n+        model['is_moderator'] = is_moderator\n+        model['is_read_only'] = is_read_only\n+        model['is_muted'] = is_muted\n+        model['is_banned'] = is_banned\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nnew file mode 100644\nindex 0000000..e1c0007\n--- /dev/null\n+++ b/src/snek/service/channel_message.py\n@@ -0,0 +1,14 @@\n+from snek.system.service import BaseService\n+\n+\n+class ChannelMessageService(BaseService):\n+    mapper_name = \"channel_message\"\n+\n+    async def create(self, channel_uid, user_uid, message):\n+        model = await self.new()\n+        model['channel_uid'] = channel_uid\n+        model['user_uid'] = user_uid\n+        model['message'] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nnew file mode 100644\nindex 0000000..e154323\n--- /dev/null\n+++ b/src/snek/service/notification.py\n@@ -0,0 +1,30 @@\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class NotificationService(BaseService):\n+    mapper_name = \"notification\"\n+\n+    async def create(self, object_uid, object_type, user_uid, message):\n+        model = await self.new()\n+        model['object_uid'] = object_uid\n+        model['object_type'] = object_type\n+        model['user_uid'] = user_uid\n+        model['message'] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create notification: {model.errors}.\")\n+    \n+    async def create_channel_message(self, channel_message_uid):\n+        channel_message = await self.services.channel_message.get(uid=channel_message_uid)\n+        user = await self.services.user.get(uid=channel_message['user_uid'])\n+        async for channel_member in self.services.channel_member.find(channel_uid=channel_message['channel_uid'],is_banned=False,is_muted=False, deleted_at=None):\n+            model = await self.new()\n+            model['object_uid'] = channel_message_uid\n+            model['object_type'] = \"channel_message\"\n+            model['user_uid'] = channel_member['user_uid']\n+            model['message'] = f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            if await self.save(model):\n+                return model\n+            raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex cfcd6b8..11d7489 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -7,10 +7,8 @@ class UserService(BaseService):\n \n     async def validate_login(self, username, password):\n         model = await self.get(username=username)\n-        print(\"FOUND USER!\", model, flush=True)\n         if not model:\n             return False\n-        print(\"AU\", password, model.password.value, flush=True)\n         if not await security.verify(password, model[\"password\"]):\n             return False\n         return True\n@@ -19,9 +17,14 @@ class UserService(BaseService):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n+        model['nick'] = username\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\n         if await self.save(model):\n+            if model:\n+                channel = await self.services.channel.ensure_public_channel(model['uid'])\n+                if not channel:\n+                    raise Exception(\"Failed to create public channel.\")\n             return model\n         raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 5e275d9..2854e7a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,7 +1,97 @@\n import functools\n+import json\n+import uuid \n+from snek.system import security \n \n cache = functools.cache\n \n+CACHE_MAX_ITEMS_DEFAULT=5000\n+\n+class Cache:\n+    def __init__(self, app,max_items=CACHE_MAX_ITEMS_DEFAULT):\n+        self.app = app\n+        self.cache = {}\n+        self.max_items = max_items\n+        self.lru = []\n+        self.version = ((42+420+1984+1990+10+6+71+3004+7245)^1337)+4\n+\n+    async def get(self, args):\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except:\n+            print(\"Cache miss!\",args,flush=True)\n+            return None\n+        self.lru.insert(0, args)\n+        while(len(self.lru) > self.max_items):\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+        print(\"Cache hit!\",args,flush=True)\n+        return self.cache[args]\n+\n+    def json_default(self, value):\n+        try:\n+            return json.dumps(value.__dict__, default=str)\n+        except:\n+            return str(value)\n+\n+    async def create_cache_key(self, args, kwargs):\n+        return await security.hash(json.dumps({\"args\": args, \"kwargs\": kwargs}, sort_keys=True,default=self.json_default))\n+\n+    async def set(self, args, result):\n+        is_new = not args in self.cache\n+        self.cache[args] = result\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except(ValueError, IndexError):\n+            pass \n+        self.lru.insert(0,args)\n+\n+        while(len(self.lru) > self.max_items):\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+\n+        if is_new:\n+            self.version += 1\n+            print(\"New version:\",self.version,flush=True)\n+\n+    async def delete(self, args):\n+        if args in self.cache:  \n+            try:\n+                self.lru.pop(self.lru.index(args))\n+            except IndexError:\n+                pass \n+            del self.cache[args]\n+\n+    def async_cache(self,func):\n+        @functools.wraps(func)\n+        async def wrapper(*args,**kwargs):\n+            cache_key = await self.create_cache_key(args,kwargs)\n+            cached = await self.get(cache_key)\n+            if cached:\n+                return cached\n+            result = await func(*args,**kwargs)\n+            await self.set(cache_key,result)\n+            return result\n+        return wrapper\n+\n+\n+\n+    def async_delete_cache(self,func):\n+        @functools.wraps(func)\n+        async def wrapper(*args,**kwargs):\n+            cache_key = await self.create_cache_key(args,kwargs)\n+            if cache_key in self.cache:\n+                try:\n+                    self.lru.pop(self.lru.index(cache_key))\n+                except IndexError:\n+                    pass \n+                del self.cache[cache_key]\n+            return await func(*args, **kwargs)\n+            \n+        return wrapper\n+\n \n def async_cache(func):\n     cache = {}\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 489ff90..66946d8 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -54,7 +54,10 @@ class BaseMapper:\n         if not kwargs.get(\"_limit\"):\n             kwargs[\"_limit\"] = self.default_limit\n         for record in self.table.find(**kwargs):\n-            yield await self.model_class.from_record(mapper=self, record=record)\n+            model = await self.new()\n+            for key, value in record.items():\n+                model[key] = value\n+            yield model\n \n     async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nnew file mode 100644\nindex 0000000..c6d1571\n--- /dev/null\n+++ b/src/snek/system/object.py\n@@ -0,0 +1,15 @@\n+\n+\n+class Object:\n+    \n+    def __init__(self, *args, **kwargs):\n+        for arg in args:\n+            if isinstance(arg,dict):\n+                self.__dict__.update(arg)\n+        self.__dict__.update(kwargs)\n+    \n+    def __getitem__(self, key):\n+        return self.__dict__[key]\n+    \n+    def __setitem__(self, key, value):\n+        self.__dict__[key] = value\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 1f9d601..942c77c 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -7,14 +7,23 @@ class BaseService:\n \n     mapper_name: BaseMapper = None\n \n+    @property \n+    def services(self):\n+        return self.app.services \n+    \n     def __init__(self, app):\n         self.app = app\n+        self.cache = app.cache\n         if self.mapper_name:\n             self.mapper = get_mapper(self.mapper_name, app=self.app)\n         else:\n             self.mapper = None\n \n-    async def exists(self, **kwargs):\n+    async def exists(self,uid=None, **kwargs):\n+        if uid:\n+            if not kwargs and await self.cache.get(uid):\n+                return True \n+            kwargs['uid'] = uid\n         return await self.count(**kwargs) > 0\n \n     async def count(self, **kwargs):\n@@ -23,15 +32,30 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n-    async def get(self, **kwargs):\n-        return await self.mapper.get(**kwargs)\n+    async def get(self,uid=None, **kwargs):\n+        if uid:\n+            if not kwargs:\n+                result = await self.cache.get(uid)\n+                if result:\n+                    return result\n+            kwargs['uid'] = uid \n+            \n+        result = await self.mapper.get(**kwargs)\n+        if result:\n+            await self.cache.set(result['uid'], result)\n+        return result\n \n     async def save(self, model: UserModel):\n-        return await self.mapper.save(model) and True\n+        if  await self.mapper.save(model):\n+            await self.cache.set(model['uid'], model)\n+            return True \n+        errors = await model.errors\n+        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n-        return await self.mapper.find(**kwargs)\n+        async for model in self.mapper.find(**kwargs):\n+            yield model\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 1074615..bec52ed 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -20,8 +20,8 @@ class BaseView(web.View):\n     def db(self):\n         return self.app.db\n \n-    async def json_response(self, data):\n-        return web.json_response(data)\n+    async def json_response(self, data,**kwargs):\n+        return web.json_response(data,**kwargs)\n \n     @property\n     def session(self):\ndiff --git a/src/snek/view/__init__.py b/src/snek/view/__init__.py\nnew file mode 100644\nindex 0000000..e69de29\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 6566df9..338699a 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,18 +1,25 @@\n-from snek.form.register import RegisterForm\n-from snek.system.view import BaseView\n+from snek.form.login import LoginForm\n+from snek.system.view import BaseFormView, BaseView\n+from aiohttp import web \n \n-\n-class LoginView(BaseView):\n+class LoginView(BaseFormView):\n+    form = LoginForm\n \n     async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n         return await self.render_template(\n             \"login.html\"\n+        )  \n \n-    async def post(self):\n-        form = RegisterForm()\n-        form.set_user_data(await self.request.post())\n-        print(form.is_valid())\n-        return await self.render_template(\n-            \"login.html\", self.request\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\n+\n+    \n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 1186959..8910cf0 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,7 +1,25 @@\n-from snek.system.view import BaseView\n+from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView, BaseView\n+from aiohttp import web\n \n+class RegisterView(BaseFormView):\n+\n+    form = RegisterForm\n \n-class RegisterView(BaseView):\n \n     async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n         return await self.render_template(\"register.html\")\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session[\"uid\"] = result[\"uid\"]\n+        self.request.session[\"username\"] = result[\"username\"]\n+        self.request.session[\"logged_in\"] = True\n+\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex add86a6..5918fa6 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,13 +1,31 @@\n from snek.system.view import BaseView\n-\n+import json\n \n class StatusView(BaseView):\n     async def get(self):\n+        \n+        memberships = []\n+        user = {}\n+        \n+        if self.session.get(\"uid\"):\n+            user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+            if not user:\n+                return await self.json_response({\"error\": \"User not found\"}, status=404)\n+            async for model in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                channel = await self.app.services.channel.get(uid=model['channel_uid'])\n+                memberships.append(dict(name=channel['label'],description=model['description'],user_uid=model['user_uid'],is_moderator=model['is_moderator'],is_read_only=model['is_read_only'],is_muted=model['is_muted'],is_banned=model['is_banned'],channel_uid=model['channel_uid'],uid=model['uid']))\n+            user = dict(\n+                username=user['username'],\n+                email=user['email'],\n+                nick=user['nick'],\n+                uid=user['uid'],\n+                memberships=memberships\n+            )\n+        \n+\n         return await self.json_response(\n             {\n-                \"status\": \"ok\",\n-                \"username\": self.session.get(\"username\"),\n-                \"logged_in\": self.session.get(\"username\") and True or False,\n-                \"uid\": self.session.get(\"uid\"),\n+                \"user\": user,\n+                \"cache\": await self.app.cache.create_cache_key(self.app.cache.cache,None)\n             }\n         )"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor mapper and model initialization for clarity\n\nfix: Corrected a typo in ChannelMessageMapper table_name\n\nstyle: Improved formatting in mapper and model files", "commit": "f25feeeca3502eee94554e7152ca7ca946115053", "diff": "commit f25feeeca3502eee94554e7152ca7ca946115053\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Jan 25 22:28:33 2025 +0100\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex bc0884a..80be7b5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,5 +1,4 @@\n import pathlib\n-from types import SimpleNamespace\n \n from aiohttp import web\n from aiohttp_session import (\n@@ -21,10 +20,8 @@ from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n-from snek.view.login_form import LoginFormView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n-from snek.view.register_form import RegisterFormView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 1f29d73..be22534 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-from types import SimpleNamespace\n \n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n@@ -11,11 +10,13 @@ from snek.system.object import Object\n @functools.cache\n def get_mappers(app=None):\n     return Object(\n-        **{\"user\": UserMapper(app=app),\n-            'channel_member': ChannelMemberMapper(app=app),\n-            'channel': ChannelMapper(app=app),\n-            'channel_message': ChannelMessageMapper(app=app)    \n-    })\n+        **{\n+            \"user\": UserMapper(app=app),\n+            \"channel_member\": ChannelMemberMapper(app=app),\n+            \"channel\": ChannelMapper(app=app),\n+            \"channel_message\": ChannelMessageMapper(app=app),\n+        }\n+    )\n \n \n def get_mapper(name, app=None):\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex 364c1ee..35ccbe9 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -4,4 +4,4 @@ from snek.system.mapper import BaseMapper\n \n class ChannelMessageMapper(BaseMapper):\n     model_class = ChannelMessageModel\n-    table_name = \"channel_message\"\n\\ No newline at end of file\n+    table_name = \"channel_message\"\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex ccb5289..c87d39c 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -2,7 +2,8 @@ import functools\n \n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n+\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.user import UserModel\n from snek.system.object import Object\n@@ -10,10 +11,14 @@ from snek.system.object import Object\n \n @functools.cache\n def get_models():\n-    return Object(**{\"user\": UserModel,\n+    return Object(\n+        **{\n+            \"user\": UserModel,\n             \"channel_member\": ChannelMemberModel,\n             \"channel\": ChannelModel,\n-            \"channel_message\": ChannelMessageModel})\n+            \"channel_message\": ChannelMessageModel,\n+        }\n+    )\n \n \n def get_model(name):\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 50b1181..d664087 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,11 +1,11 @@\n from snek.system.model import BaseModel, ModelField\n \n-class ChannelModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True,kind=str)\n-    description = ModelField(name=\"description\", required=False,kind=str)\n-    tag = ModelField(name=\"tag\", required=False,kind=str)\n-    created_by_uid = ModelField(name=\"created_by_uid\", required=True,kind=str)\n-    is_private = ModelField(name=\"is_private\", required=True,kind=bool,value=False)\n-    is_listed = ModelField(name=\"is_listed\", required=True,kind=bool,value=True)\n-    index = ModelField(name=\"index\", required=True,kind=int,value=1000) \n \n+class ChannelModel(BaseModel):\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    description = ModelField(name=\"description\", required=False, kind=str)\n+    tag = ModelField(name=\"tag\", required=False, kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n+    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 48131e4..d199498 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,10 +1,15 @@\n from snek.system.model import BaseModel, ModelField\n \n+\n class ChannelMemberModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True,kind=str)\n-    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n-    is_moderator = ModelField(name=\"is_moderator\", required=True,kind=bool,value=False)\n-    is_read_only = ModelField(name=\"is_read_only\", required=True,kind=bool,value=False)\n-    is_muted = ModelField(name=\"is_muted\", required=True,kind=bool,value=False)\n-    is_banned = ModelField(name=\"is_banned\", required=True,kind=bool,value=False)\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    is_moderator = ModelField(\n+        name=\"is_moderator\", required=True, kind=bool, value=False\n+    )\n+    is_read_only = ModelField(\n+        name=\"is_read_only\", required=True, kind=bool, value=False\n+    )\n+    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 9e96307..0fab568 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,9 +1,7 @@\n-\n-\n from snek.system.model import BaseModel, ModelField\n \n \n class ChannelMessageModel(BaseModel):\n-    channel_uid = ModelField(name=\"channel_uid\", required=True,kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True,kind=str)\n-    message = ModelField(name=\"message\", required=True,kind=str)\n\\ No newline at end of file\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    message = ModelField(name=\"message\", required=True, kind=str)\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex 0a5c294..6a12328 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,6 +1,3 @@\n-\n-\n-\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -9,4 +6,4 @@ class NotificationModel(BaseModel):\n     object_type = ModelField(name=\"object_type\", required=True)\n     message = ModelField(name=\"message\", required=True)\n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    read_at = ModelField(name=\"is_read\", required=True)\n\\ No newline at end of file\n+    read_at = ModelField(name=\"is_read\", required=True)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 8457917..c81a456 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,21 +1,20 @@\n import functools\n \n from snek.service.channel import ChannelService\n-from snek.service.user import UserService\n from snek.service.channel_member import ChannelMemberService\n-from types import SimpleNamespace\n-\n+from snek.service.user import UserService\n from snek.system.object import Object\n \n+\n @functools.cache\n def get_services(app):\n     return Object(\n         **{\n             \"user\": UserService(app=app),\n             \"channel_member\": ChannelMemberService(app=app),\n-            'channel': ChannelService(app=app)\n+            \"channel\": ChannelService(app=app),\n         }\n-)\n+    )\n \n \n def get_service(name, app=None):\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 9290baf..ee23c2d 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,31 +1,48 @@\n from snek.system.service import BaseService\n \n+\n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n-    async def create(self, label, created_by_uid,  description=None, tag=None, is_private=False, is_listed=True):\n+    async def create(\n+        self,\n+        label,\n+        created_by_uid,\n+        description=None,\n+        tag=None,\n+        is_private=False,\n+        is_listed=True,\n+    ):\n         count = await self.count(deleted_at=None)\n         if not tag and not count:\n             tag = \"public\"\n         model = await self.new()\n-        model['label'] = label\n-        model['description'] = description\n-        model['tag'] = tag \n-        model['created_by_uid'] = created_by_uid\n-        model['is_private'] = is_private\n-        model['is_listed'] = is_listed\n+        model[\"label\"] = label\n+        model[\"description\"] = description\n+        model[\"tag\"] = tag\n+        model[\"created_by_uid\"] = created_by_uid\n+        model[\"is_private\"] = is_private\n+        model[\"is_listed\"] = is_listed\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n-    \n+\n     async def ensure_public_channel(self, created_by_uid):\n-        model = await self.get(is_listed=True,tag=\"public\")\n-        is_moderator = False \n+        model = await self.get(is_listed=True, tag=\"public\")\n+        is_moderator = False\n         if not model:\n-            is_moderator = True \n-            model = await self.create(\"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\")\n-        await self.app.services.channel_member.create(model['uid'], created_by_uid, is_moderator=is_moderator, is_read_only=False, is_muted=False, is_banned=False)\n+            is_moderator = True\n+            model = await self.create(\n+                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n+            )\n+        await self.app.services.channel_member.create(\n+            model[\"uid\"],\n+            created_by_uid,\n+            is_moderator=is_moderator,\n+            is_read_only=False,\n+            is_muted=False,\n+            is_banned=False,\n+        )\n         return model\n-    \n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 18d4842..adbcded 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,24 +1,33 @@\n-from snek.system.service import BaseService \n+from snek.system.service import BaseService\n+\n \n class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n-    async def create(self, channel_uid, user_uid, is_moderator=False, is_read_only=False, is_muted=False, is_banned=False):\n+    async def create(\n+        self,\n+        channel_uid,\n+        user_uid,\n+        is_moderator=False,\n+        is_read_only=False,\n+        is_muted=False,\n+        is_banned=False,\n+    ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n             if model.is_banned.value:\n-                return False \n+                return False\n             return model\n         model = await self.new()\n         channel = await self.services.channel.get(uid=channel_uid)\n-        model['label'] = channel['label']\n-        model['channel_uid'] = channel_uid\n-        model['user_uid'] = user_uid\n-        model['is_moderator'] = is_moderator\n-        model['is_read_only'] = is_read_only\n-        model['is_muted'] = is_muted\n-        model['is_banned'] = is_banned\n+        model[\"label\"] = channel[\"label\"]\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"is_moderator\"] = is_moderator\n+        model[\"is_read_only\"] = is_read_only\n+        model[\"is_muted\"] = is_muted\n+        model[\"is_banned\"] = is_banned\n         if await self.save(model):\n             return model\n-        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n\\ No newline at end of file\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex e1c0007..d86de26 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -6,9 +6,9 @@ class ChannelMessageService(BaseService):\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n-        model['channel_uid'] = channel_uid\n-        model['user_uid'] = user_uid\n-        model['message'] = message\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n         if await self.save(model):\n             return model\n-        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n\\ No newline at end of file\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex e154323..3815c60 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,5 +1,3 @@\n-\n-\n from snek.system.service import BaseService\n \n \n@@ -8,23 +6,32 @@ class NotificationService(BaseService):\n \n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n-        model['object_uid'] = object_uid\n-        model['object_type'] = object_type\n-        model['user_uid'] = user_uid\n-        model['message'] = message\n+        model[\"object_uid\"] = object_uid\n+        model[\"object_type\"] = object_type\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")\n-    \n+\n     async def create_channel_message(self, channel_message_uid):\n-        channel_message = await self.services.channel_message.get(uid=channel_message_uid)\n-        user = await self.services.user.get(uid=channel_message['user_uid'])\n-        async for channel_member in self.services.channel_member.find(channel_uid=channel_message['channel_uid'],is_banned=False,is_muted=False, deleted_at=None):\n+        channel_message = await self.services.channel_message.get(\n+            uid=channel_message_uid\n+        )\n+        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n             model = await self.new()\n-            model['object_uid'] = channel_message_uid\n-            model['object_type'] = \"channel_message\"\n-            model['user_uid'] = channel_member['user_uid']\n-            model['message'] = f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n             if await self.save(model):\n                 return model\n             raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 11d7489..60825a5 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -17,13 +17,15 @@ class UserService(BaseService):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n-        model['nick'] = username\n+        model[\"nick\"] = username\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\n         if await self.save(model):\n             if model:\n-                channel = await self.services.channel.ensure_public_channel(model['uid'])\n+                channel = await self.services.channel.ensure_public_channel(\n+                    model[\"uid\"]\n+                )\n                 if not channel:\n                     raise Exception(\"Failed to create public channel.\")\n             return model\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 2854e7a..86f5557 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,35 +1,36 @@\n import functools\n import json\n-import uuid \n-from snek.system import security \n+\n+from snek.system import security\n \n cache = functools.cache\n \n-CACHE_MAX_ITEMS_DEFAULT=5000\n+CACHE_MAX_ITEMS_DEFAULT = 5000\n+\n \n class Cache:\n-    def __init__(self, app,max_items=CACHE_MAX_ITEMS_DEFAULT):\n+    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n         self.app = app\n         self.cache = {}\n         self.max_items = max_items\n         self.lru = []\n-        self.version = ((42+420+1984+1990+10+6+71+3004+7245)^1337)+4\n+        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n-            print(\"Cache miss!\",args,flush=True)\n+            print(\"Cache miss!\", args, flush=True)\n             return None\n         self.lru.insert(0, args)\n-        while(len(self.lru) > self.max_items):\n+        while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n-        print(\"Cache hit!\",args,flush=True)\n+        print(\"Cache hit!\", args, flush=True)\n         return self.cache[args]\n \n     def json_default(self, value):\n         try:\n             return json.dumps(value.__dict__, default=str)\n@@ -37,59 +38,64 @@ class Cache:\n             return str(value)\n \n     async def create_cache_key(self, args, kwargs):\n-        return await security.hash(json.dumps({\"args\": args, \"kwargs\": kwargs}, sort_keys=True,default=self.json_default))\n+        return await security.hash(\n+            json.dumps(\n+                {\"args\": args, \"kwargs\": kwargs},\n+                sort_keys=True,\n+                default=self.json_default,\n+            )\n+        )\n \n     async def set(self, args, result):\n-        is_new = not args in self.cache\n+        is_new = args not in self.cache\n         self.cache[args] = result\n         try:\n             self.lru.pop(self.lru.index(args))\n-        except(ValueError, IndexError):\n-            pass \n-        self.lru.insert(0,args)\n+        except (ValueError, IndexError):\n+            pass\n+        self.lru.insert(0, args)\n \n-        while(len(self.lru) > self.max_items):\n+        while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n \n         if is_new:\n             self.version += 1\n-            print(\"New version:\",self.version,flush=True)\n+            print(\"New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n-        if args in self.cache:  \n+        if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\n             except IndexError:\n-                pass \n+                pass\n             del self.cache[args]\n \n-    def async_cache(self,func):\n+    def async_cache(self, func):\n         @functools.wraps(func)\n-        async def wrapper(*args,**kwargs):\n-            cache_key = await self.create_cache_key(args,kwargs)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n             cached = await self.get(cache_key)\n             if cached:\n                 return cached\n-            result = await func(*args,**kwargs)\n-            await self.set(cache_key,result)\n+            result = await func(*args, **kwargs)\n+            await self.set(cache_key, result)\n             return result\n-        return wrapper\n-\n \n+        return wrapper\n \n-    def async_delete_cache(self,func):\n+    def async_delete_cache(self, func):\n         @functools.wraps(func)\n-        async def wrapper(*args,**kwargs):\n-            cache_key = await self.create_cache_key(args,kwargs)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n             if cache_key in self.cache:\n                 try:\n                     self.lru.pop(self.lru.index(cache_key))\n                 except IndexError:\n-                    pass \n+                    pass\n                 del self.cache[cache_key]\n             return await func(*args, **kwargs)\n-            \n+\n         return wrapper\n \n \ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex c6d1571..f91ec42 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,15 +1,13 @@\n-\n-\n class Object:\n-    \n+\n     def __init__(self, *args, **kwargs):\n         for arg in args:\n-            if isinstance(arg,dict):\n+            if isinstance(arg, dict):\n                 self.__dict__.update(arg)\n         self.__dict__.update(kwargs)\n-    \n+\n     def __getitem__(self, key):\n         return self.__dict__[key]\n-    \n+\n     def __setitem__(self, key, value):\n-        self.__dict__[key] = value\n\\ No newline at end of file\n+        self.__dict__[key] = value\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 942c77c..60d27bb 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -7,10 +7,10 @@ class BaseService:\n \n     mapper_name: BaseMapper = None\n \n-    @property \n+    @property\n     def services(self):\n-        return self.app.services \n-    \n+        return self.app.services\n+\n     def __init__(self, app):\n         self.app = app\n         self.cache = app.cache\n@@ -19,11 +19,11 @@ class BaseService:\n         else:\n             self.mapper = None\n \n-    async def exists(self,uid=None, **kwargs):\n+    async def exists(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs and await self.cache.get(uid):\n-                return True \n-            kwargs['uid'] = uid\n+                return True\n+            kwargs[\"uid\"] = uid\n         return await self.count(**kwargs) > 0\n \n     async def count(self, **kwargs):\n@@ -32,24 +32,24 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n-    async def get(self,uid=None, **kwargs):\n+    async def get(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs:\n                 result = await self.cache.get(uid)\n                 if result:\n                     return result\n-            kwargs['uid'] = uid \n-            \n+            kwargs[\"uid\"] = uid\n+\n         result = await self.mapper.get(**kwargs)\n         if result:\n-            await self.cache.set(result['uid'], result)\n+            await self.cache.set(result[\"uid\"], result)\n         return result\n \n     async def save(self, model: UserModel):\n-        if  await self.mapper.save(model):\n-            await self.cache.set(model['uid'], model)\n-            return True \n+        if await self.mapper.save(model):\n+            await self.cache.set(model[\"uid\"], model)\n+            return True\n         errors = await model.errors\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex bec52ed..8b775e0 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -20,8 +20,8 @@ class BaseView(web.View):\n     def db(self):\n         return self.app.db\n \n-    async def json_response(self, data,**kwargs):\n-        return web.json_response(data,**kwargs)\n+    async def json_response(self, data, **kwargs):\n+        return web.json_response(data, **kwargs)\n \n     @property\n     def session(self):\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 338699a..396d11e 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,6 +1,8 @@\n+from aiohttp import web\n+\n from snek.form.login import LoginForm\n-from snek.system.view import BaseFormView, BaseView\n-from aiohttp import web \n+from snek.system.view import BaseFormView\n+\n \n class LoginView(BaseFormView):\n     form = LoginForm\n@@ -10,9 +12,7 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\n-            \"login.html\"\n-        )  \n+        return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n         if await form.is_valid:\n@@ -21,5 +21,3 @@ class LoginView(BaseFormView):\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}\n-\n-    \n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 8910cf0..eb1c8d8 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,12 +1,13 @@\n-from snek.form.register import RegisterForm\n-from snek.system.view import BaseFormView, BaseView\n from aiohttp import web\n \n+from snek.form.register import RegisterForm\n+from snek.system.view import BaseFormView\n+\n+\n class RegisterView(BaseFormView):\n \n     form = RegisterForm\n \n-\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 5918fa6..a307dee 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,31 +1,46 @@\n from snek.system.view import BaseView\n-import json\n+\n \n class StatusView(BaseView):\n     async def get(self):\n-        \n+\n         memberships = []\n         user = {}\n-        \n+\n         if self.session.get(\"uid\"):\n             user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n-            async for model in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                channel = await self.app.services.channel.get(uid=model['channel_uid'])\n-                memberships.append(dict(name=channel['label'],description=model['description'],user_uid=model['user_uid'],is_moderator=model['is_moderator'],is_read_only=model['is_read_only'],is_muted=model['is_muted'],is_banned=model['is_banned'],channel_uid=model['channel_uid'],uid=model['uid']))\n-            user = dict(\n-                username=user['username'],\n-                email=user['email'],\n-                nick=user['nick'],\n-                uid=user['uid'],\n-                memberships=memberships\n-            )\n-        \n+            async for model in self.app.services.channel_member.find(\n+                user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n+                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n+                memberships.append(\n+                    {\n+                        \"name\": channel[\"label\"],\n+                        \"description\": model[\"description\"],\n+                        \"user_uid\": model[\"user_uid\"],\n+                        \"is_moderator\": model[\"is_moderator\"],\n+                        \"is_read_only\": model[\"is_read_only\"],\n+                        \"is_muted\": model[\"is_muted\"],\n+                        \"is_banned\": model[\"is_banned\"],\n+                        \"channel_uid\": model[\"channel_uid\"],\n+                        \"uid\": model[\"uid\"],\n+                    }\n+                )\n+            user = {\n+                \"username\": user[\"username\"],\n+                \"email\": user[\"email\"],\n+                \"nick\": user[\"nick\"],\n+                \"uid\": user[\"uid\"],\n+                \"memberships\": memberships,\n+            }\n \n         return await self.json_response(\n             {\n                 \"user\": user,\n-                \"cache\": await self.app.cache.create_cache_key(self.app.cache.cache,None)\n+                \"cache\": await self.app.cache.create_cache_key(\n+                    self.app.cache.cache, None\n+                ),\n             }\n         )"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and basic WebSocket support.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df", "diff": "commit 488afdcc747df9593273f652b17b5fe8db07b1df\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:48:58 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 80be7b5..f242090 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -22,6 +22,7 @@ from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n+from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n \n@@ -77,7 +78,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-\n+        self.router.add_get(\"/rpc.ws\",RPCView)\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex e69de29..05e62d8 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -0,0 +1,9 @@\n+\n+\n+from snek.model.notification import NotificationModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class NotificationMapper(BaseMapper):\n+    table_name = \"notification\"\n+    model_class = NotificationModel\n\\ No newline at end of file\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex c81a456..db4a00f 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -2,6 +2,9 @@ import functools\n \n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n+from snek.service.channel_message import ChannelMessageService\n+from snek.service.chat import ChatService\n+from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.system.object import Object\n \n@@ -13,6 +16,9 @@ def get_services(app):\n             \"user\": UserService(app=app),\n             \"channel_member\": ChannelMemberService(app=app),\n             \"channel\": ChannelService(app=app),\n+            \"channel_message\": ChannelMessageService(app=app),\n+            \"chat\": ChatService(app=app),\n+            \"socket\": SocketService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex adbcded..a1eec8c 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,6 +28,7 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n+        print(model.record,flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 133cbcd..2be1c09 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,6 +1,6 @@\n \n \n-class Message {\n     uid = null \n     author = null\n     avatar = null \n@@ -38,7 +38,7 @@ class Message {\n         }\n         return result \n     }\n-}\n \n \n class Messages {\n@@ -204,17 +204,153 @@ class Chat extends EventHandler {\n \n }\n \n+class Socket extends EventHandler {\n+    ws = null \n+    isConnected = null\n+    isConnecting = null\n+    connectPromises = []\n+    constructor() {\n+        super()\n+        this.ensureConnection() \n+    }\n+    _camelToSnake(str) {\n+        return str\n+          .replace(/([a-z])([A-Z])/g, '$1_$2') \n+          .toLowerCase(); \n+    }\n+    get client() {\n+        const me = this\n+        const proxy = new Proxy(\n+            {},\n+            {\n+            get(target, prop) {\n+                return (...args) => {\n+                  let functionName = me._camelToSnake(prop)\n+                  return me.call(functionName, ...args);\n+                };\n+              },\n+            }\n+          );\n+        return proxy\n+    }\n+    ensureConnection(){\n+        return this.connect()\n+    }\n+    generateUniqueId() {\n+        return 'id-' + Math.random().toString(36).substr(2, 9); \n+    }\n+    connect(){\n+        const me  = this \n+        if(!this.isConnected && !this.isConnecting){\n+            this.isConnecting = true \n+        }else if (this.isConnecting){\n+            return new Promise((resolve,reject)=>{\n+                me.connectPromises.push(resolve)\n+            }) \n+        }else if(this.isConnected){\n+            return new Promise((resolve,reject)=>{\n+                resolve(me)\n+            })\n+        }\n+        return new Promise((resolve,reject)=>{\n+            me.connectPromises.push(resolve)\n+            ws.onopen = (event) => {\n+                me.ws = ws \n+                me.isConnected = true \n+                me.isConnecting = false\n+                ws.onmessage = (event) => {\n+                    me.onData(JSON.parse(event.data))\n+                }\n+                ws.onclose = (event) =>{\n+                    me.onClose()\n+                \n+                }\n+                me.connectPromises.forEach(resolve=>{\n+                  resolve(me)  \n+                })\n+            }\n+        })\n+    }\n+    onData(data){\n+        console.debug(\"Data received\",data)\n+        if(data.callId){\n+            this.emit(data.callId, data.data)\n+        }\n+        if(data.channel_uid){\n+            this.emit(data.channel_uid,data.data)\n+            this.emit(\"channel-message\",data)\n+        }\n+        \n+    }\n+    async sendJson(data){\n+        return await this.connect().then((api)=>{\n+            api.ws.send(JSON.stringify(data))\n+        })\n+    }\n+    async call(method,...args){\n+        const call= {\n+            callId: this.generateUniqueId(),\n+            method: method,\n+            args: args\n+        }\n+        \n+        const me = this \n+        return new Promise(async(resolve,reject)=>{\n+                me.addEventListener(call.callId,(data)=>{\n+                    resolve(data)\n+                })\n+                await me.sendJson(call)\n+            \n+\n+            })\n+    }\n+    onClose(){\n+        console.info(\"Connection lost. Reconnecting.\")\n+        this.isConnected = false \n+        this.isConnecting = false\n+        this.ensureConnection().then(()=>{\n+            console.info(\"Reconnected.\")\n+        })\n+    }\n+\n+}\n \n-class App {\n+class App extends EventHandler {\n     rooms = []\n+    rest = rest \n+    ws = null \n+    rpc = null \n     constructor() {\n+        super()\n         this.rooms.push(new Room(\"General\"))\n-\n-\n+        this.ws = new Socket()\n+        this.rpc = this.ws.client \n+        const me = this \n+        this.ws.addEventListener(\"channel-message\", (data) => {\n+            console.debug(\"App channel message!\",data)\n+            me.emit(data.channel_uid,data)\n+        }) \n     }\n-    async post(url, data){\n-\n+    async benchMark(times) {\n+        if(!times)\n+            times = 100\n+        let promises = []\n+        const me = this \n+        for(let i = 0; i < times; i++){\n+            promises.push(this.rpc.getChannels().then(channels=>{\n+                channels.forEach(channel=>{\n+                    me.rpc.sendMessage(channel.uid,`Haha ${i}`).then(data=>{\n+                        console.info(data)\n+                    })\n+                })\n+            }))\n+            \n+        }\n+        return await Promise.all(promises)\n     }\n \n \n-}\n\\ No newline at end of file\n+}\n+\n+const app = new App()\n\\ No newline at end of file\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 263f1eb..b674b8d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -109,6 +109,12 @@ main {\n }\n \n+.message-list-manager {\n+  flex: 1;\n+  overflow-y: auto;\n+}\n+\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8b775e0..cd9112d 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -19,6 +19,10 @@ class BaseView(web.View):\n     @property\n     def db(self):\n         return self.app.db\n+    \n+    @property\n+    def services(self):\n+        return self.app.services\n \n     async def json_response(self, data, **kwargs):\n         return web.json_response(data, **kwargs)\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex cb8fc5d..36a012c 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -5,6 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>{% block title %}{% endblock %}</title>\n   <script src=\"/app.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n   <style>{{ highlight_styles }}</style>\n   <link rel=\"stylesheet\" href=\"/style.css\">\n    <script src=\"/fancy-button.js\"></script>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ae0bb06..d56b4f2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,6 +5,9 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <script src=\"/app.js\"></script>\n+  <script src=\"/models.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n+  <script src=\"/message-list-manager.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n@@ -31,31 +34,23 @@\n       <div class=\"chat-header\">\n         <h2>General</h2>\n       </div>\n-      <div class=\"chat-messages\">\n-        <div class=\"message\">\n-          <div class=\"avatar\">A</div>\n-          <div class=\"message-content\">\n-            <div class=\"author\">Alice</div>\n-            <div class=\"text\">Hello, everyone!</div>\n-            <div class=\"time\">10:45 AM</div>\n-          </div>\n-        </div>\n-        <html-frame class=\"html-frame\" url=\"/register\"></html-frame>\n-        <div class=\"message\">\n-          <div class=\"avatar\">B</div>\n-          <div class=\"message-content\">\n-            <div class=\"author\">Bob</div>\n-            <div class=\"text\">Hi Alice! How are you?</div>\n-            <div class=\"time\">10:46 AM</div>\n-          </div>\n-        </div>\n-      </div>\n+      <message-list-manager class=\"message-list-manager\"></message-list-manager>\n       <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <button>Send</button>\n       </div>\n     </section>\n   </main>\n-  \n+  <script>\n+    document.addEventListener(\"DOMContentLoaded\",()=>{\n+      setTimeout(()=>{\n+      app.benchMark(3).then(result=>{\n+        console.info(\"Benchmarked\")\n+      })\n+    },1000)\n+      \n+    })\n+\n+  </script>\n </body>\n </html>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-26", "line": "fix: Minor formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58", "diff": "commit 4c601e8333b3a462c63ab6e02b73b9f5306b4a58\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:49:37 2025 +0100\n\n    Format.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f242090..7546ae0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -78,7 +78,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-        self.router.add_get(\"/rpc.ws\",RPCView)\n+        self.router.add_get(\"/rpc.ws\", RPCView)\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex 05e62d8..9bd74b5 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,9 +1,7 @@\n-\n-\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n \n \n class NotificationMapper(BaseMapper):\n     table_name = \"notification\"\n-    model_class = NotificationModel\n\\ No newline at end of file\n+    model_class = NotificationModel\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex a1eec8c..6b9ce9e 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,7 +28,7 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n-        print(model.record,flush=True)\n+        print(model.record, flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex cd9112d..8c7537e 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -19,7 +19,7 @@ class BaseView(web.View):\n     @property\n     def db(self):\n         return self.app.db\n-    \n+\n     @property\n     def services(self):\n         return self.app.services"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f", "diff": "commit 4ae846cf8b4f1158ac47ce2825d37e03e9b6677f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:51:51 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nnew file mode 100644\nindex 0000000..3dd67e3\n--- /dev/null\n+++ b/src/snek/service/chat.py\n@@ -0,0 +1,43 @@\n+\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class ChatService(BaseService):\n+\n+    async def send(self,user_uid, channel_uid, message):\n+        channel_message = await self.services.channel_message.create(\n+            user_uid, \n+            channel_uid, \n+            message\n+        )\n+        channel_message_uid = channel_message[\"uid\"]\n+        \n+        user = await self.services.user.get(uid=user_uid)\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            model = await self.new()\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n+            if not await self.services.channel_member.save(model):\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\n+            \n+        sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n+            message=message,\n+            user_uid=user_uid,\n+            channel_uid=channel_uid,\n+            created_at=channel_message[\"created_at\"], \n+            updated_at=None,\n+            uid=channel_message['uid'],\n+            user_nick=user['nick']\n+        ))\n+        return sent_to_count\n\\ No newline at end of file\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nnew file mode 100644\nindex 0000000..eb4695c\n--- /dev/null\n+++ b/src/snek/service/socket.py\n@@ -0,0 +1,33 @@\n+\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class SocketService(BaseService):\n+\n+    def __init__(self, app):\n+        super().__init__(app)\n+        self.sockets = set()\n+        self.subscriptions = {}\n+\n+    async def add(self, ws):\n+        self.sockets.add(ws)\n+\n+    async def subscribe(self, ws, channel_uid):\n+        if not channel_uid in self.subscriptions:\n+            self.subscriptions[channel_uid] = set()\n+        self.subscriptions[channel_uid].add(ws)\n+\n+    async def broadcast(self, channel_uid, message):\n+        print(\"BROADCAT!\",message)\n+        count = 0\n+        for ws in self.subscriptions.get(channel_uid,[]):\n+            await ws.send_json(message)\n+            count += 1\n+        return count\n+    async def delete(self, ws):\n+        try:\n+            self.sockets.remove(ws) \n+        except IndexError:\n+            pass \n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nnew file mode 100644\nindex 0000000..69a3f87\n--- /dev/null\n+++ b/src/snek/static/message-list-manager.js\n@@ -0,0 +1,23 @@\n+\n+\n+class MessageListManagerElement extends HTMLElement {\n+    constructor() {\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.container = document.createElement(\"div\")\n+        this.shadowRoot.appendChild(this.container)\n+    }\n+\n+    async connectedCallback() {\n+        let channels = await app.rpc.getChannels()\n+        const me = this \n+        channels.forEach(channel=>{\n+            const messageList = document.createElement(\"message-list\")\n+            messageList.setAttribute(\"channel\",channel.uid)\n+            me.container.appendChild(messageList)\n+        })\n+    }\n+\n+}\n+\n+customElements.define(\"message-list-manager\",MessageListManagerElement)\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nnew file mode 100644\nindex 0000000..c1d7b3d\n--- /dev/null\n+++ b/src/snek/static/message-list.js\n@@ -0,0 +1,86 @@\n+\n+\n+class MessageListElement extends HTMLElement {\n+    \n+    static get observedAttributes() {\n+        return [\"messages\"];\n+    }\n+    messages = []\n+    room = null\n+    url = null\n+    container = null\n+    constructor() {\n+        super()\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div')\n+        this.shadowRoot.appendChild(this.component )\n+    }\n+    createElement(message){\n+        const element = document.createElement(\"div\")\n+\n+        element.classList.add(\"message\")\n+        const avatar = document.createElement(\"div\")\n+        avatar.classList.add(\"avatar\")\n+        avatar.innerText = message.user_nick[0]\n+        const messageContent = document.createElement(\"div\")\n+        messageContent.classList.add(\"message-content\")\n+        const author = document.createElement(\"div\")\n+        author.classList.add(\"author\")\n+        author.textContent = message.user_nick\n+        const text = document.createElement(\"div\")\n+        text.classList.add(\"text\")\n+        text.textContent = message.message\n+        const time = document.createElement(\"div\")\n+        time.classList.add(\"time\")\n+        time.textContent = message.created_at\n+        messageContent.appendChild(author)\n+        messageContent.appendChild(text)\n+        messageContent.appendChild(time)\n+        element.appendChild(avatar)\n+        element.appendChild(messageContent)\n+\n+\n+\n+        message.element = element \n+       \n+        return element\n+    }\n+    addMessage(message){\n+        \n+        const obj = new models.Message(\n+            message.uid,\n+            message.channel_uid,\n+            message.user_uid,\n+            message.user_nick,\n+            message.message,\n+            message.created_at,\n+            message.updated_at\n+        )\n+        const element = this.createElement(obj)\n+        this.messages.push(obj)\n+        this.container.appendChild(element)\n+        return obj\n+    }\n+    connectedCallback() {\n+        const link = document.createElement('link')\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement('div')\n+        this.container.classList.add(\"chat-messages\")\n+        this.component.appendChild(this.container)\n+        \n+        this.messages = []\n+        this.channel_uid = this.getAttribute(\"channel\")\n+        const me = this\n+        app.addEventListener(this.channel_uid, (data) => {\n+            console.info(\"WIIIIIIIIIIIIIIIIIIIIIIII\")\n+            me.addMessage(data)\n+        })\n+        this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n+        \n+        \n+    }\n+}\n+\n+customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nnew file mode 100644\nindex 0000000..6589279\n--- /dev/null\n+++ b/src/snek/static/models.js\n@@ -0,0 +1,22 @@\n+class MessageModel {\n+    message = null \n+    user_uid = null \n+    channel_uid = null \n+    created_at = null \n+    updated_at = null  \n+    element = null \n+    constructor(uid, channel_uid,user_uid,user_nick, message,created_at, updated_at){\n+        this.uid = uid \n+        this.message = message \n+        this.user_uid = user_uid \n+        this.user_nick = user_nick\n+        this.channel_uid = channel_uid  \n+        this.created_at = created_at\n+        this.updated_at = updated_at\n+    }       \n+}\n+\n+const models = {\n+    Message: MessageModel\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nnew file mode 100644\nindex 0000000..b3611ec\n--- /dev/null\n+++ b/src/snek/view/rpc.py\n@@ -0,0 +1,75 @@\n+from aiohttp import web  \n+from snek.system.view import BaseView\n+\n+\n+class RPCView(BaseView):\n+\n+    login_required = True\n+\n+    class RPCApi:\n+        def __init__(self,view, ws):\n+            self.view = view \n+            self.app = self.view.app\n+            self.services = self.app.services\n+            self.user_uid = self.view.session.get(\"uid\")\n+            self.ws = ws \n+            \n+       \n+        async def get_channels(self):\n+            channels = []\n+            async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n+                channels.append(dict(\n+                    name=subscription[\"label\"],\n+                    uid=subscription[\"channel_uid\"],\n+                    is_moderator=subscription[\"is_moderator\"],\n+                    is_read_only=subscription[\"is_read_only\"]\n+                ))\n+            return channels\n+\n+        async def send_message(self, room, message):\n+            await self.services.chat.send(self.user_uid,room,message)\n+            return True \n+        \n+\n+        async def echo(self,*args):\n+            return args\n+\n+\n+\n+\n+\n+        async def __call__(self, data):\n+            call_id = data.get(\"callId\")\n+            method_name = data.get(\"method\")\n+            args = data.get(\"args\")\n+            if hasattr(super(),method_name) or not hasattr(self,method_name):\n+                return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+\n+            method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+            result = await method(*args)\n+            await self.ws.send_json({\"callId\":call_id,\"data\":result})\n+\n+\n+    async def call_ping(self,callId,*args):\n+        return {\"pong\": args}\n+\n+\n+    async def get(self):\n+\n+        \n+        ws = web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        await self.services.socket.add(ws)\n+        async for subscription in self.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+            await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n+            print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n+        rpc = RPCView.RPCApi(self,ws)\n+        async for msg in ws:\n+            if msg.type == web.WSMsgType.TEXT:\n+                await rpc(msg.json())\n+            elif msg.type == web.WSMsgType.ERROR:\n+                print(f\"WebSocket exception {ws.exception()}\")\n+\n+        await self.services.socket.delete(ws)\n+        print(\"WebSocket connection closed\")\n+        return ws\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Use dynamic websocket URL based on environment", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4", "diff": "commit fb7cb35921b73fd22a4ef045fe23b8dab87a7af4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Jan 26 22:54:29 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 2be1c09..f6490e8 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -208,9 +208,11 @@ class Socket extends EventHandler {\n     ws = null \n     isConnected = null\n     isConnecting = null\n+    url = null\n     connectPromises = []\n     constructor() {\n         super()\n         this.ensureConnection() \n     }\n     _camelToSnake(str) {\n@@ -254,7 +256,8 @@ class Socket extends EventHandler {\n         }\n         return new Promise((resolve,reject)=>{\n             me.connectPromises.push(resolve)\n+            \n+            const ws = new WebSocket(this.url)\n             ws.onopen = (event) => {\n                 me.ws = ws \n                 me.isConnected = true"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improve socket error handling and update websocket URL\n\nrefactor: Implement query methods in mapper and service\n\nfeat: Add chat window component and update templates\n\nfeat: Implement get_messages endpoint for RPC view", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44", "diff": "commit 36c69eb8bb35068faebd396af1375fe5927eec44\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 00:56:06 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex eb4695c..55909ce 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -23,7 +23,11 @@ class SocketService(BaseService):\n         print(\"BROADCAT!\",message)\n         count = 0\n         for ws in self.subscriptions.get(channel_uid,[]):\n-            await ws.send_json(message)\n+            try:\n+                await ws.send_json(message)\n+            except Exception as ex:\n+                print(ex)\n+                continue \n             count += 1\n         return count\n     async def delete(self, ws):\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f6490e8..1e98ec5 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -212,7 +212,7 @@ class Socket extends EventHandler {\n     connectPromises = []\n     constructor() {\n         super()\n         this.ensureConnection() \n     }\n     _camelToSnake(str) {\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 66946d8..9722534 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -59,6 +59,10 @@ class BaseMapper:\n                 model[key] = value\n             yield model\n \n+    async def query(self, sql, *args):\n+        for record in self.db.query(sql, *args):\n+            yield dict(record)\n+\n     async def delete(self, kwargs=None) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 60d27bb..51e4b9f 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -32,6 +32,10 @@ class BaseService:\n     async def new(self, **kwargs):\n         return await self.mapper.new()\n \n+    async def query(self, sql, *args):\n+        for record in self.app.db.query(sql, *args):\n+            yield record\n+\n     async def get(self, uid=None, **kwargs):\n         if uid:\n             if not kwargs:\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d56b4f2..f6635d3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -8,6 +8,7 @@\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>\n   <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n <body>\n@@ -30,12 +31,10 @@\n       </ul>\n     </aside>\n-    <section class=\"chat-area\">\n-      <div class=\"chat-header\">\n-        <h2>General</h2>\n-      </div>\n-      <message-list-manager class=\"message-list-manager\"></message-list-manager>\n-      <div class=\"chat-input\">\n+    <section>\n+    <chat-window class=\"chat-area\"></chat-window>\n+  \n+    <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <button>Send</button>\n       </div>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b3611ec..6936780 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -15,6 +15,19 @@ class RPCView(BaseView):\n             self.ws = ws \n             \n        \n+        async def get_messages(self, channel_uid,offset=0):\n+            messages = []\n+            async for message in self.services.channel_message.query(\"SELECT channel_uid, user_uid, message, created_at FROM channel_message WHERE channel_uid = :channel_uid ORDER BY created_at DESC LIMIT 30 OFFSET :offset\",{\"channel_uid\":channel_uid,\"offset\":int(offset)}):\n+                messages.append(dict(\n+                    uid=message[\"uid\"],\n+                    user_uid=message[\"user_uid\"],\n+                    channel_uid=message[\"channel_uid\"],\n+                    user_nick=(await self.services.user.get(uid=message[\"user_uid\"]))[\"nick\"],\n+                    message=message[\"message\"],\n+                    created_at=message[\"created_at\"]\n+                ))\n+            return messages\n+        \n         async def get_channels(self):\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688", "diff": "commit 87895a72d3ddb5f3ca98e4409f251e663e6dd688\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 00:56:16 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nnew file mode 100644\nindex 0000000..f9b7328\n--- /dev/null\n+++ b/src/snek/static/chat-window.js\n@@ -0,0 +1,43 @@\n+\n+\n+class ChatWindowElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n+    }\n+\n+    async connectedCallback() {\n+        const link = document.createElement('link')\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement(\"section\")\n+        this.container.classList.add(\"chat-area\")\n+        this.container.classList.add(\"chat-window\")\n+        \n+        const chatHeader = document.createElement(\"div\")\n+        chatHeader.classList.add(\"chat-header\")\n+        const chatTitle = document.createElement('h2')\n+        chatTitle.classList.add(\"chat-title\")\n+        chatTitle.innerText = \"Loading...\"\n+        chatHeader.appendChild(chatTitle)\n+        this.container.appendChild(chatHeader)\n+        const channels = await app.rpc.getChannels()\n+        const channel = channels[0]\n+        chatTitle.innerText = channel.name \n+        const channelElement = document.createElement('message-list')\n+        channelElement.setAttribute(\"channel\", channel.uid)\n+        this.container.appendChild(channelElement)\n+        this.component.appendChild(this.container)\n+        console.info(channel)\n+        const messages = await app.rpc.getMessages(channel.uid)\n+        console.info(messages)\n+        await app.rpc.sendMessage(channel.uid,\"hello world\")\n+    }\n+\n+\n+}\n+\n+customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and configured database path", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3", "diff": "commit aec9ffd1a1a49acad8940b793be6ff3abcae07a3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 01:06:13 2025 +0100\n\n    Persistance.\n\ndiff --git a/.gitignore b/.gitignore\nindex ece77be..43eb3eb 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -3,6 +3,7 @@\n .resources\n .backup*\n docs\n+snek.d*\n *.db*\n *.png\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7546ae0..b1c20c0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -107,7 +107,7 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n-app = Application()\n \n if __name__ == \"__main__\":"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added notification service and related mappings and services.", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d", "diff": "commit 4f71f745744b1a413a729875bc42366ea3ab665d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 02:57:51 2025 +0100\n\n    Persistance.\n\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex be22534..1841346 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -3,6 +3,7 @@ import functools\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n+from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.system.object import Object\n \n@@ -15,6 +16,7 @@ def get_mappers(app=None):\n             \"channel_member\": ChannelMemberMapper(app=app),\n             \"channel\": ChannelMapper(app=app),\n             \"channel_message\": ChannelMessageMapper(app=app),\n+            \"notification\": NotificationMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex db4a00f..6a8f76c 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -4,6 +4,7 @@ from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n from snek.service.chat import ChatService\n+from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.system.object import Object\n@@ -19,6 +20,7 @@ def get_services(app):\n             \"channel_message\": ChannelMessageService(app=app),\n             \"chat\": ChatService(app=app),\n             \"socket\": SocketService(app=app),\n+            \"notification\": NotificationService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 3dd67e3..74ca94e 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -8,29 +8,14 @@ class ChatService(BaseService):\n \n     async def send(self,user_uid, channel_uid, message):\n         channel_message = await self.services.channel_message.create(\n-            user_uid, \n             channel_uid, \n+            user_uid, \n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n         \n         user = await self.services.user.get(uid=user_uid)\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            model = await self.new()\n-            model[\"object_uid\"] = channel_message_uid\n-            model[\"object_type\"] = \"channel_message\"\n-            model[\"user_uid\"] = channel_member[\"user_uid\"]\n-            model[\"message\"] = (\n-                f\"New message from {user['nick']} in {channel_member['label']}.\"\n-            )\n-            if not await self.services.channel_member.save(model):\n-                raise Exception(f\"Failed to create notification: {model.errors}.\")\n-            \n+        await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n             message=message,\n             user_uid=user_uid,\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex b674b8d..5447efb 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,11 +101,15 @@ main {\n   font-size: 1.2em;\n }\n-\n+message-list {\n+  flex: 1;;\n+   height: 200px;\n+  overflow-y: auto;\n+}\n .chat-messages {\n   flex: 1;\n   padding: 20px;\n-  overflow-y: auto;\n+  height: 200px;\n }\n \ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex f9b7328..35e4e78 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -4,7 +4,7 @@ class ChatWindowElement extends HTMLElement {\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div');\n+        this.component = document.createElement('section');\n         this.shadowRoot.appendChild(this.component);\n     }\n \n@@ -13,6 +13,7 @@ class ChatWindowElement extends HTMLElement {\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n         this.component.appendChild(link)\n+        this.component.classList.add(\"chat-area\")\n         this.container = document.createElement(\"section\")\n         this.container.classList.add(\"chat-area\")\n         this.container.classList.add(\"chat-window\")\n@@ -29,12 +30,33 @@ class ChatWindowElement extends HTMLElement {\n         chatTitle.innerText = channel.name \n         const channelElement = document.createElement('message-list')\n         channelElement.setAttribute(\"channel\", channel.uid)\n         this.container.appendChild(channelElement)\n+\n+        const chatInput = document.createElement('chat-input')\n+        chatInput.classList.add(\"chat-input\")\n+        chatInput.addEventListener(\"submit\",(e)=>{\n+            app.rpc.sendMessage(channel.uid,e.detail)\n+        })\n+        this.container.appendChild(chatInput)\n+\n         this.component.appendChild(this.container)\n         console.info(channel)\n         const messages = await app.rpc.getMessages(channel.uid)\n         console.info(messages)\n-        await app.rpc.sendMessage(channel.uid,\"hello world\")\n+        messages.forEach(message=>{\n+            if(!message['user_nick'])\n+                return\n+            channelElement.addMessage(message)\n+        })\n+        const me = this\n+        channelElement.addEventListener(\"message\",(message)=>{\n+            console.info(\"ROCKSTARTSS\")\n+            setTimeout(()=>{\n+            message.detail.element.scrollIntoView({behavior: 'smooth'})\n+        },10)\n+    })\n+        await app.rpc.sendMessage(channel.uid,\"Grrrrr\")\n     }\n \n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex c1d7b3d..84207d2 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -13,11 +13,12 @@ class MessageListElement extends HTMLElement {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n+        \n         this.shadowRoot.appendChild(this.component )\n     }\n     createElement(message){\n         const element = document.createElement(\"div\")\n-\n+        \n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n@@ -59,15 +60,22 @@ class MessageListElement extends HTMLElement {\n         const element = this.createElement(obj)\n         this.messages.push(obj)\n         this.container.appendChild(element)\n+        const me = this \n+        this.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        \n         return obj\n     }\n+    scrollBottom(){\n+        this.container.scrollTop = this.container.scrollHeight;\n+    }\n     connectedCallback() {\n         const link = document.createElement('link')\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n         this.component.appendChild(link)\n+        this.component.classList.add(\"chat-messages\")\n         this.container = document.createElement('div')\n-        this.container.classList.add(\"chat-messages\")\n         this.component.appendChild(this.container)\n         \n         this.messages = []\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f6635d3..0184a97 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -8,6 +8,7 @@\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>\n   <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n </head>\n@@ -31,14 +32,7 @@\n       </ul>\n     </aside>\n-    <section>\n     <chat-window class=\"chat-area\"></chat-window>\n-  \n-    <div class=\"chat-input\">\n-        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-        <button>Send</button>\n-      </div>\n-    </section>\n   </main>\n   <script>\n     document.addEventListener(\"DOMContentLoaded\",()=>{\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 6936780..1b08d04 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -17,12 +17,19 @@ class RPCView(BaseView):\n        \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n-            async for message in self.services.channel_message.query(\"SELECT channel_uid, user_uid, message, created_at FROM channel_message WHERE channel_uid = :channel_uid ORDER BY created_at DESC LIMIT 30 OFFSET :offset\",{\"channel_uid\":channel_uid,\"offset\":int(offset)}):\n+                print(\"JEEEHHH\\n\",flush=True)\n+\n+                user = await self.services.user.get(uid=message[\"user_uid\"])\n+                if not user:\n+                    print(\"User not found!\",flush= True)\n+                    continue\n+\n                 messages.append(dict(\n                     uid=message[\"uid\"],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],\n-                    user_nick=(await self.services.user.get(uid=message[\"user_uid\"]))[\"nick\"],\n+                    user_nick=user['nick'],\n                     message=message[\"message\"],\n                     created_at=message[\"created_at\"]\n                 ))"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Add chat input element with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c", "diff": "commit 2a3e225e1dbb40374e841af8977ff19cd4711f0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 02:58:28 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nnew file mode 100644\nindex 0000000..fcf925b\n--- /dev/null\n+++ b/src/snek/static/chat-input.js\n@@ -0,0 +1,40 @@\n+\n+\n+class ChatInputElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n+    }\n+    connectedCallback() {\n+        const link = document.createElement(\"link\")\n+        link.rel = 'stylesheet'\n+        link.href = '/base.css'\n+        this.component.appendChild(link)\n+        this.container = document.createElement('div')\n+        this.container.classList.add(\"chat-input\")\n+        this.container.innerHTML = `\n+            <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+            <button>Send</button>\n+        `;\n+        this.container.querySelector('textarea').addEventListener('input', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"input\", {detail:e.target.value,bubbles:true}))\n+            const message = e.target.value;\n+            const button = this.container.querySelector('button');\n+            button.disabled = !message;\n+        })\n+        this.container.querySelector('textarea').addEventListener('change',(e)=>{\n+            this.dispatchEvent(new CustomEvent(\"change\", {detail:e.target.value,bubbles:true}))\n+            console.error(e.target.value)\n+        })\n+        this.container.querySelector('textarea').addEventListener('keyup', (e) => {\n+            if(e.key == 'Enter' && !e.shiftKey){\n+                this.dispatchEvent(new CustomEvent(\"submit\", {detail:e.target.value,bubbles:true}))\n+                e.target.value = ''\n+            }\n+        })\n+        this.component.appendChild(this.container)\n+    }\n+}\n+customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a", "diff": "commit 188a1e61783a7d08cb7ece1fbcd332aa1f19672a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:04:43 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 396d11e..c9904f5 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -16,8 +16,9 @@ class LoginView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n+            user = await self.services.user.get(username=form.username.value,deleted_at=None)\n             self.session[\"logged_in\"] = True\n             self.session[\"username\"] = form.username.value\n-            self.session[\"uid\"] = form.uid.value\n+            self.session[\"uid\"] = user[\"uid\"]\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove unnecessary console statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e", "diff": "commit 26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:16:44 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 1e98ec5..21afc38 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -78,7 +78,7 @@ class Page {\n }\n \n class RESTClient {\n-    debug = true \n+    debug = false \n     \n     async get(url, params){\n         params = params ? params : {} \n@@ -276,7 +276,6 @@ class Socket extends EventHandler {\n         })\n     }\n     onData(data){\n-        console.debug(\"Data received\",data)\n         if(data.callId){\n             this.emit(data.callId, data.data)\n         }\n@@ -331,7 +330,6 @@ class App extends EventHandler {\n         this.rpc = this.ws.client \n         const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            console.debug(\"App channel message!\",data)\n             me.emit(data.channel_uid,data)\n         }) \n     }\n@@ -350,7 +348,8 @@ class App extends EventHandler {\n             }))\n             \n         }\n-        return await Promise.all(promises)\n+    \n     }"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Remove console logs and disable test message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490", "diff": "commit 095e30a92f6d12edf16ca87d66b335088b853490\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:37:04 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 35e4e78..f6b0102 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -41,9 +41,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(chatInput)\n \n         this.component.appendChild(this.container)\n-        console.info(channel)\n         const messages = await app.rpc.getMessages(channel.uid)\n-        console.info(messages)\n         messages.forEach(message=>{\n             if(!message['user_nick'])\n                 return\n@@ -56,7 +54,7 @@ class ChatWindowElement extends HTMLElement {\n             message.detail.element.scrollIntoView({behavior: 'smooth'})\n         },10)\n     })\n-        await app.rpc.sendMessage(channel.uid,\"Grrrrr\")\n+        \n     }\n \n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 84207d2..7e17afb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -82,7 +82,6 @@ class MessageListElement extends HTMLElement {\n         this.channel_uid = this.getAttribute(\"channel\")\n         const me = this\n         app.addEventListener(this.channel_uid, (data) => {\n-            console.info(\"WIIIIIIIIIIIIIIIIIIIIIIII\")\n             me.addMessage(data)\n         })\n         this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove unnecessary benchmark script", "commit": "374db23669e203c98e5335b9a7abe9aff2110537", "diff": "commit 374db23669e203c98e5335b9a7abe9aff2110537\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:38:46 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0184a97..03ef013 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -34,16 +34,5 @@\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>\n-  <script>\n-    document.addEventListener(\"DOMContentLoaded\",()=>{\n-      setTimeout(()=>{\n-      app.benchMark(3).then(result=>{\n-        console.info(\"Benchmarked\")\n-      })\n-    },1000)\n-      \n-    })\n-\n-  </script>\n </body>\n </html>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Improved socket handling and cache logging", "commit": "f3d12a257e7a43e3292654d7f67f05d823f16283", "diff": "commit f3d12a257e7a43e3292654d7f67f05d823f16283\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 03:48:53 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 55909ce..b7f172a 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -27,6 +27,8 @@ class SocketService(BaseService):\n                 await ws.send_json(message)\n             except Exception as ex:\n                 print(ex)\n+                print(\"Deleting socket.\")\n+                self.subscriptions[channel_uid].remove(ws)\n                 continue \n             count += 1\n         return count\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 86f5557..39e6fc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(\"New version:\", self.version, flush=True)\n+            print(\"Cache store! New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove generated pycache file", "commit": "8e825a90c6e575f114b380312bb9c5726577b8b7", "diff": "commit 8e825a90c6e575f114b380312bb9c5726577b8b7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Jan 27 05:12:02 2025 +0100\n\n    Deleted pycache.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\ndeleted file mode 100644\nindex 6f355a6..0000000\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and /dev/null differ"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit table results to 30", "commit": "01d8093e7210910016ea5d6d8bbc5d8f2514c14d", "diff": "commit 01d8093e7210910016ea5d6d8bbc5d8f2514c14d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:28:43 2025 +0100\n\n    Added 30 limit on all tables.\n\ndiff --git a/.gitignore b/.gitignore\nindex 43eb3eb..5e03233 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -4,6 +4,8 @@\n .backup*\n docs\n snek.d*\n+.rcontext.txt \n+*.zip\n *.db*\n *.png\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex d86de26..4b1a6f8 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -12,3 +12,5 @@ class ChannelMessageService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n+    \n+    \ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 51e4b9f..baf2086 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -58,6 +58,8 @@ class BaseService:\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n+        if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+            kwargs[\"_limit\"] = 30\n         async for model in self.mapper.find(**kwargs):\n             yield model"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit messages to 30 in RPCView", "commit": "d93d48ef7e023c62bfa9b64ede20cd9f86c3242e", "diff": "commit d93d48ef7e023c62bfa9b64ede20cd9f86c3242e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:32:23 2025 +0100\n\n    Added 30 limit on all tables.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 1b08d04..3ac54a3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -17,7 +17,7 @@ class RPCView(BaseView):\n        \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 print(\"JEEEHHH\\n\",flush=True)\n \n                 user = await self.services.user.get(uid=message[\"user_uid\"])"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Improved message handling and added scheduling for event dispatch", "commit": "da72a15068fe14eeb2b50b4cd3342fb4b70b0c79", "diff": "commit da72a15068fe14eeb2b50b4cd3342fb4b70b0c79\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 15:52:13 2025 +0100\n\n    Removed pyc files.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex f6b0102..8acf595 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -49,10 +49,8 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            console.info(\"ROCKSTARTSS\")\n-            setTimeout(()=>{\n             message.detail.element.scrollIntoView({behavior: 'smooth'})\n-        },10)\n+        \n     })\n         \n     }\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7e17afb..a45cd6d 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -9,6 +9,7 @@ class MessageListElement extends HTMLElement {\n     room = null\n     url = null\n     container = null\n+    messageEventSchedule = null \n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n@@ -40,6 +41,7 @@ class MessageListElement extends HTMLElement {\n         element.appendChild(avatar)\n         element.appendChild(messageContent)\n \n+    \n \n \n         message.element = element \n@@ -61,8 +63,12 @@ class MessageListElement extends HTMLElement {\n         this.messages.push(obj)\n         this.container.appendChild(element)\n         const me = this \n-        this.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n         \n+        this.messageEventSchedule.delay(() => {\n+            me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        })\n+       \n+\n         return obj\n     }\n     scrollBottom(){\n@@ -77,7 +83,7 @@ class MessageListElement extends HTMLElement {\n         this.container = document.createElement('div')\n         this.component.appendChild(this.container)\n-        \n+        this.messageEventSchedule = new Schedule(500)\n         this.messages = []\n         this.channel_uid = this.getAttribute(\"channel\")\n         const me = this\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nnew file mode 100644\nindex 0000000..fefb503\n--- /dev/null\n+++ b/src/snek/static/schedule.js\n@@ -0,0 +1,45 @@\n+\n+\n+class Schedule {\n+\n+    constructor(msDelay) {\n+        if(!msDelay){\n+            msDelay = 100\n+        }\n+        this.msDelay = msDelay\n+        this._once = false\n+        this.timeOutCount = 0;\n+        this.timeOut = null \n+        this.interval = null \n+    }\n+    cancelRepeat() {\n+        clearInterval(this.interval)\n+        this.interval = null \n+    }\n+    cancelDelay() {\n+        clearTimeout(this.interval)\n+        this.interval = null\n+    }\n+    repeat(func){\n+        if(this.interval){\n+            return false \n+        }\n+        this.interval = setInterval(()=>{\n+            func()\n+        }, this.msDelay)\n+    }\n+    delay(func) {\n+        this.timeOutCount++\n+        if(this.timeOut){\n+            this.cancelDelay()\n+        }\n+        const me = this \n+        this.timeOut = setTimeout(()=>{\n+            clearTimeout(me.timeOut)\n+            me.timeOut = null\n+            me.cancelDelay()\n+            me.timeOutCount = 0\n+        }, this.msDelay)\n+    }\n+\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 03ef013..05ef008 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>\n   <script src=\"/message-list.js\"></script>"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule functionality and minor UI adjustments", "commit": "99d335ac244c2258d82821344fa517857a782f4a", "diff": "commit 99d335ac244c2258d82821344fa517857a782f4a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:08:18 2025 +0100\n\n    Added schedule.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 5447efb..6fffd16 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -104,6 +104,7 @@ main {\n message-list {\n   flex: 1;;\n    height: 200px;\n+   padding-bottom: 40px;\n   overflow-y: auto;\n }\n .chat-messages {\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 8acf595..0727225 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -1,10 +1,12 @@\n \n \n class ChatWindowElement extends HTMLElement {\n+    receivedHistory = false\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n+        \n         this.shadowRoot.appendChild(this.component);\n     }\n \n@@ -49,9 +51,10 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            message.detail.element.scrollIntoView({behavior: 'smooth'})\n-        \n+            message.detail.element.scrollIntoView()\n+         \n     })\n+\n         \n     }\n \ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex a45cd6d..29e8a0b 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -10,11 +10,11 @@ class MessageListElement extends HTMLElement {\n     url = null\n     container = null\n     messageEventSchedule = null \n+    observer = null \n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n-        \n         this.shadowRoot.appendChild(this.component )\n     }\n     createElement(message){\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex fefb503..0eb41d1 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -37,6 +37,7 @@ class Schedule {\n         this.timeOut = setTimeout(()=>{\n             clearTimeout(me.timeOut)\n             me.timeOut = null\n+            func(me.timeOutCount)\n             me.cancelDelay()\n             me.timeOutCount = 0\n         }, this.msDelay)\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3ac54a3..514db45 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -18,6 +18,7 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n+                \n                 print(\"JEEEHHH\\n\",flush=True)\n \n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n@@ -25,7 +26,7 @@ class RPCView(BaseView):\n                     print(\"User not found!\",flush= True)\n                     continue\n \n-                messages.append(dict(\n+                messages.insert(0,dict(\n                     uid=message[\"uid\"],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule and benchmark message parameter", "commit": "4f1a48c197fcad25d80873bac55cf66f7ff99382", "diff": "commit 4f1a48c197fcad25d80873bac55cf66f7ff99382\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:11:30 2025 +0100\n\n    Added schedule.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 21afc38..810e6bc 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -333,16 +333,18 @@ class App extends EventHandler {\n             me.emit(data.channel_uid,data)\n         }) \n     }\n-    async benchMark(times) {\n+    async benchMark(times,message) {\n         if(!times)\n             times = 100\n+        if(!message)\n+            message = \"Benchmark Message\"\n         let promises = []\n         const me = this \n         for(let i = 0; i < times; i++){\n             promises.push(this.rpc.getChannels().then(channels=>{\n                 channels.forEach(channel=>{\n-                    me.rpc.sendMessage(channel.uid,`Haha ${i}`).then(data=>{\n-                        console.info(data)\n+                    me.rpc.sendMessage(channel.uid,`${message} ${i}`).then(data=>{\n+                        \n                     })\n                 })\n             }))"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound on new message", "commit": "5aee606d5d65e71afa8366d24ed4632f662a9126", "diff": "commit 5aee606d5d65e71afa8366d24ed4632f662a9126\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:24:10 2025 +0100\n\n    Added notification sound.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 810e6bc..1d56076 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -51,12 +51,12 @@ class Messages {\n \n \n class Room {\n-    name = null \n+    name = null\n     messages = []\n-    constructor(name){\n-        this.name = name \n+    constructor(name) {\n+        this.name = name\n     }\n-    setMessages(list){\n+    setMessages(list) {\n \n     }\n \n@@ -65,9 +65,9 @@ class Room {\n \n \n class InlineAppElement extends HTMLElement {\n-    \n-    constructor(){\n+\n+    constructor() {\n     }\n \n }\n@@ -78,38 +78,38 @@ class Page {\n }\n \n class RESTClient {\n-    debug = false \n-    \n-    async get(url, params){\n-        params = params ? params : {} \n+    debug = false\n+\n+    async get(url, params) {\n+        params = params ? params : {}\n         const encodedParams = new URLSearchParams(params);\n-        if(encodedParams)\n+        if (encodedParams)\n             url += '?' + encodedParams\n-        const response = await fetch(url,{\n+        const response = await fetch(url, {\n             method: 'GET',\n             headers: {\n-              'Content-Type': 'application/json'\n+                'Content-Type': 'application/json'\n             }\n-          });\n-          const result =  await response.json()\n-          if(this.debug){\n-            console.debug({url:url,params:params,result:result})\n-          }\n-        return result \n+        });\n+        const result = await response.json()\n+        if (this.debug) {\n+            console.debug({ url: url, params: params, result: result })\n+        }\n+        return result\n     }\n     async post(url, data) {\n-    const response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify(data)\n-      });\n-\n-      const result =  await response.json()\n-          if(this.debug){\n-            console.debug({url:url,params:params,result:result})\n-          }\n+        const response = await fetch(url, {\n+            method: 'POST',\n+            headers: {\n+                'Content-Type': 'application/json'\n+            },\n+            body: JSON.stringify(data)\n+        });\n+\n+        const result = await response.json()\n+        if (this.debug) {\n+            console.debug({ url: url, params: params, result: result })\n+        }\n         return result\n     }\n }\n@@ -117,17 +117,17 @@ const rest = new RESTClient()\n \n class EventHandler {\n \n-    constructor(){\n+    constructor() {\n         this.subscribers = {}\n     }\n-    addEventListener(type,handler){\n-        if(!this.subscribers[type])\n+    addEventListener(type, handler) {\n+        if (!this.subscribers[type])\n             this.subscribers[type] = []\n         this.subscribers[type].push(handler)\n     }\n-    emit(type,...data){\n-        if(this.subscribers[type])\n-            this.subscribers[type].forEach(handler=>handler(...data))\n+    emit(type, ...data) {\n+        if (this.subscribers[type])\n+            this.subscribers[type].forEach(handler => handler(...data))\n     }\n \n }\n@@ -136,182 +136,182 @@ class Chat extends EventHandler {\n \n     constructor() {\n         super()\n-        this._socket = null \n-        this._wait_connect = null \n+        this._socket = null\n+        this._wait_connect = null\n         this._promises = {}\n     }\n-    connect(){\n-        if(this._wait_connect)\n+    connect() {\n+        if (this._wait_connect)\n             return this._wait_connect\n-        \n-            const me = this \n-            return new Promise(async (resolve,reject)=>{\n-                me._wait_connect = resolve \n-                me._socket = new WebSocket(me._url)\n-                console.debug(\"Connecting..\")\n-                \n-                me._socket.onconnect = ()=>{\n-                    me._connected()\n-                    me._wait_socket(me)\n-                }\n-            }) \n-        \n+\n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            me._wait_connect = resolve\n+            me._socket = new WebSocket(me._url)\n+            console.debug(\"Connecting..\")\n+\n+            me._socket.onconnect = () => {\n+                me._connected()\n+                me._wait_socket(me)\n+            }\n+        })\n+\n     }\n     generateUniqueId() {\n     }\n-    call(method,...args){\n-        const me = this \n-        return new Promise(async (resolve,reject)=>{\n-            try{\n-                const command = {method:method,args:args,message_id:me.generateUniqueId()}\n+    call(method, ...args) {\n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            try {\n+                const command = { method: method, args: args, message_id: me.generateUniqueId() }\n                 me._promises[command.message_id] = resolve\n-                await me._socket.send(JSON.stringify(command)) \n-               \n-            }catch(e){\n+                await me._socket.send(JSON.stringify(command))\n+\n+            } catch (e) {\n                 reject(e)\n             }\n         })\n     }\n     _connected() {\n-        const me = this \n+        const me = this\n         this._socket.onmessage = (event) => {\n             const message = JSON.parse(event.data)\n-            if(message.message_id && me._promises[message.message_id]){\n+            if (message.message_id && me._promises[message.message_id]) {\n                 me._promises[message.message_id](message)\n                 delete me._promises[message.message_id]\n-            }else{\n-            me.emit(\"message\",me, message)\n+            } else {\n+                me.emit(\"message\", me, message)\n             }\n         }\n         this._socket.onclose = (event) => {\n-            me._wait_socket = null \n-            me._socket = null \n-            me.emit('close',me)\n+            me._wait_socket = null\n+            me._socket = null\n+            me.emit('close', me)\n         }\n     }\n \n     async privmsg(room, text) {\n-        await rest.post(\"/api/privmsg\",{\n-            room:room,\n-            text:text\n+        await rest.post(\"/api/privmsg\", {\n+            room: room,\n+            text: text\n         })\n     }\n \n }\n \n class Socket extends EventHandler {\n-    ws = null \n+    ws = null\n     isConnected = null\n     isConnecting = null\n     url = null\n     connectPromises = []\n     constructor() {\n         super()\n-        this.ensureConnection() \n+        this.ensureConnection()\n     }\n     _camelToSnake(str) {\n         return str\n-          .replace(/([a-z])([A-Z])/g, '$1_$2') \n-          .toLowerCase(); \n+            .replace(/([a-z])([A-Z])/g, '$1_$2')\n+            .toLowerCase();\n     }\n     get client() {\n         const me = this\n         const proxy = new Proxy(\n             {},\n             {\n-            get(target, prop) {\n-                return (...args) => {\n-                  let functionName = me._camelToSnake(prop)\n-                  return me.call(functionName, ...args);\n-                };\n-              },\n+                get(target, prop) {\n+                    return (...args) => {\n+                        let functionName = me._camelToSnake(prop)\n+                        return me.call(functionName, ...args);\n+                    };\n+                },\n             }\n-          );\n+        );\n         return proxy\n     }\n-    ensureConnection(){\n+    ensureConnection() {\n         return this.connect()\n     }\n     generateUniqueId() {\n-        return 'id-' + Math.random().toString(36).substr(2, 9); \n+        return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n-    connect(){\n-        const me  = this \n-        if(!this.isConnected && !this.isConnecting){\n-            this.isConnecting = true \n-        }else if (this.isConnecting){\n-            return new Promise((resolve,reject)=>{\n+    connect() {\n+        const me = this\n+        if (!this.isConnected && !this.isConnecting) {\n+            this.isConnecting = true\n+        } else if (this.isConnecting) {\n+            return new Promise((resolve, reject) => {\n                 me.connectPromises.push(resolve)\n-            }) \n-        }else if(this.isConnected){\n-            return new Promise((resolve,reject)=>{\n+            })\n+        } else if (this.isConnected) {\n+            return new Promise((resolve, reject) => {\n                 resolve(me)\n             })\n         }\n-        return new Promise((resolve,reject)=>{\n+        return new Promise((resolve, reject) => {\n             me.connectPromises.push(resolve)\n-            \n+\n             const ws = new WebSocket(this.url)\n             ws.onopen = (event) => {\n-                me.ws = ws \n-                me.isConnected = true \n+                me.ws = ws\n+                me.isConnected = true\n                 me.isConnecting = false\n                 ws.onmessage = (event) => {\n                     me.onData(JSON.parse(event.data))\n                 }\n-                ws.onclose = (event) =>{\n+                ws.onclose = (event) => {\n                     me.onClose()\n-                \n+\n                 }\n-                me.connectPromises.forEach(resolve=>{\n-                  resolve(me)  \n+                me.connectPromises.forEach(resolve => {\n+                    resolve(me)\n                 })\n             }\n         })\n     }\n-    onData(data){\n-        if(data.callId){\n+    onData(data) {\n+        if (data.callId) {\n             this.emit(data.callId, data.data)\n         }\n-        if(data.channel_uid){\n-            this.emit(data.channel_uid,data.data)\n-            this.emit(\"channel-message\",data)\n+        if (data.channel_uid) {\n+            this.emit(data.channel_uid, data.data)\n+            this.emit(\"channel-message\", data)\n         }\n-        \n+\n     }\n-    async sendJson(data){\n-        return await this.connect().then((api)=>{\n+    async sendJson(data) {\n+        return await this.connect().then((api) => {\n             api.ws.send(JSON.stringify(data))\n         })\n     }\n-    async call(method,...args){\n-        const call= {\n+    async call(method, ...args) {\n+        const call = {\n             callId: this.generateUniqueId(),\n             method: method,\n             args: args\n         }\n-        \n-        const me = this \n-        return new Promise(async(resolve,reject)=>{\n-                me.addEventListener(call.callId,(data)=>{\n-                    resolve(data)\n-                })\n-                await me.sendJson(call)\n-            \n \n+        const me = this\n+        return new Promise(async (resolve, reject) => {\n+            me.addEventListener(call.callId, (data) => {\n+                resolve(data)\n             })\n+            await me.sendJson(call)\n+\n+\n+        })\n     }\n-    onClose(){\n+    onClose() {\n         console.info(\"Connection lost. Reconnecting.\")\n-        this.isConnected = false \n+        this.isConnected = false\n         this.isConnecting = false\n-        this.ensureConnection().then(()=>{\n+        this.ensureConnection().then(() => {\n             console.info(\"Reconnected.\")\n         })\n     }\n@@ -320,38 +320,53 @@ class Socket extends EventHandler {\n \n class App extends EventHandler {\n     rooms = []\n-    rest = rest \n-    ws = null \n-    rpc = null \n+    rest = rest\n+    ws = null\n+    rpc = null\n+    sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n+    playSound(soundIndex) {\n+        if (!soundIndex)\n+            soundIndex = 0\n+\n+        const player = new Audio(this.sounds[soundIndex]);\n+\n+        player.play()\n+            .then(() => {\n+                console.debug(\"Gave sound notification\")\n+            })\n+            .catch((error) => {\n+                console.error(\"Notification failed:\", error);\n+            });\n+    }\n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n-        this.rpc = this.ws.client \n-        const me = this \n+        this.rpc = this.ws.client\n+        const me = this\n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid,data)\n-        }) \n+            me.emit(data.channel_uid, data)\n+        })\n     }\n-    async benchMark(times,message) {\n-        if(!times)\n+    async benchMark(times, message) {\n+        if (!times)\n             times = 100\n-        if(!message)\n+        if (!message)\n             message = \"Benchmark Message\"\n         let promises = []\n-        const me = this \n-        for(let i = 0; i < times; i++){\n-            promises.push(this.rpc.getChannels().then(channels=>{\n-                channels.forEach(channel=>{\n-                    me.rpc.sendMessage(channel.uid,`${message} ${i}`).then(data=>{\n-                        \n+        const me = this\n+        for (let i = 0; i < times; i++) {\n+            promises.push(this.rpc.getChannels().then(channels => {\n+                channels.forEach(channel => {\n+                    me.rpc.sendMessage(channel.uid, `${message} ${i}`).then(data => {\n+\n                     })\n                 })\n             }))\n-            \n+\n         }\n-    \n+\n     }\n \n \ndiff --git a/src/snek/static/audio/soundfx.d_alarm1.mp3 b/src/snek/static/audio/soundfx.d_alarm1.mp3\nnew file mode 100644\nindex 0000000..372e3b4\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_alarm1.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_alarm2.mp3 b/src/snek/static/audio/soundfx.d_alarm2.mp3\nnew file mode 100644\nindex 0000000..85bb50a\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_alarm2.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep1.mp3 b/src/snek/static/audio/soundfx.d_beep1.mp3\nnew file mode 100644\nindex 0000000..0aae9fd\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep1.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep2.mp3 b/src/snek/static/audio/soundfx.d_beep2.mp3\nnew file mode 100644\nindex 0000000..171f0e4\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep2.mp3 differ\ndiff --git a/src/snek/static/audio/soundfx.d_beep3.mp3 b/src/snek/static/audio/soundfx.d_beep3.mp3\nnew file mode 100644\nindex 0000000..883c5d3\nBinary files /dev/null and b/src/snek/static/audio/soundfx.d_beep3.mp3 differ\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0727225..9e0e648 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -51,6 +51,7 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n+            app.playSound(0)\n             message.detail.element.scrollIntoView()\n          \n     })\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 29e8a0b..a6fc835 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -66,6 +66,7 @@ class MessageListElement extends HTMLElement {\n         \n         this.messageEventSchedule.delay(() => {\n             me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n+        \n         })"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound and improved chat input functionality", "commit": "14c59ba5c0abc7d1331e022cc99222223ea21526", "diff": "commit 14c59ba5c0abc7d1331e022cc99222223ea21526\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 17:37:10 2025 +0100\n\n    Added notification sound.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex b7f172a..0c11208 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -22,7 +22,8 @@ class SocketService(BaseService):\n     async def broadcast(self, channel_uid, message):\n         print(\"BROADCAT!\",message)\n         count = 0\n-        for ws in self.subscriptions.get(channel_uid,[]):\n+        subscriptions = set(self.subscriptions.get(channel_uid,[]))\n+        for ws in subscriptions:\n             try:\n                 await ws.send_json(message)\n             except Exception as ex:\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex fcf925b..1ea2c2e 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -8,6 +8,7 @@ class ChatInputElement extends HTMLElement {\n         this.shadowRoot.appendChild(this.component);\n     }\n     connectedCallback() {\n+        const me = this\n         const link = document.createElement(\"link\")\n         link.rel = 'stylesheet'\n         link.href = '/base.css'\n@@ -18,22 +19,31 @@ class ChatInputElement extends HTMLElement {\n             <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n             <button>Send</button>\n         `;\n-        this.container.querySelector('textarea').addEventListener('input', (e) => {\n-            this.dispatchEvent(new CustomEvent(\"input\", {detail:e.target.value,bubbles:true}))\n+        this.textBox = this.container.querySelector('textarea')\n+        this.textBox.addEventListener('input', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"input\", { detail: e.target.value, bubbles: true }))\n             const message = e.target.value;\n             const button = this.container.querySelector('button');\n             button.disabled = !message;\n         })\n-        this.container.querySelector('textarea').addEventListener('change',(e)=>{\n-            this.dispatchEvent(new CustomEvent(\"change\", {detail:e.target.value,bubbles:true}))\n+        this.textBox.addEventListener('change', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n-        this.container.querySelector('textarea').addEventListener('keyup', (e) => {\n-            if(e.key == 'Enter' && !e.shiftKey){\n-                this.dispatchEvent(new CustomEvent(\"submit\", {detail:e.target.value,bubbles:true}))\n+        this.textBox.addEventListener('keyup', (e) => {\n+            if (e.key == 'Enter' && !e.shiftKey) {\n+                this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n             }\n         })\n+\n+        this.container.querySelector('button').addEventListener('click', (e) => {\n+            this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n+            setTimeout(()=>{\n+                me.textBox.value = ''\n+                me.textBox.focus()\n+            },200)\n+        })\n         this.component.appendChild(this.container)\n     }\n }\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 9e0e648..c5e3d61 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -36,7 +36,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(channelElement)\n \n         const chatInput = document.createElement('chat-input')\n-        chatInput.classList.add(\"chat-input\")\n+        \n         chatInput.addEventListener(\"submit\",(e)=>{\n             app.rpc.sendMessage(channel.uid,e.detail)\n         })"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Improved connection handling and PWA support", "commit": "b2ca373081bdd7514b0f849dc1033edfd3f76424", "diff": "commit b2ca373081bdd7514b0f849dc1033edfd3f76424\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 20:41:24 2025 +0100\n\n    Reconnector.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 1d56076..a2d45d4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -148,9 +148,17 @@ class Chat extends EventHandler {\n         const me = this\n         return new Promise(async (resolve, reject) => {\n             me._wait_connect = resolve\n-            me._socket = new WebSocket(me._url)\n             console.debug(\"Connecting..\")\n \n+            try {\n+                me._socket = new WebSocket(me._url)\n+            }catch(e){\n+                console.warning(e)\n+                setTimeout(()=>{\n+                    me.ensureConnection()\n+                },1000)\n+            }\n+\n             me._socket.onconnect = () => {\n                 me._connected()\n                 me._wait_socket(me)\n@@ -210,6 +218,7 @@ class Socket extends EventHandler {\n     isConnecting = null\n     url = null\n     connectPromises = []\n+    ensureTimer = null \n     constructor() {\n         super()\n@@ -236,6 +245,14 @@ class Socket extends EventHandler {\n         return proxy\n     }\n     ensureConnection() {\n+        if(this.ensureTimer)\n+            return this.connect()\n+        const me  = this \n+        this.ensureTimer = setInterval(()=>{\n+            if (me.isConnecting)\n+                me.isConnecting = false\n+            me.connect()\n+        },5000)\n         return this.connect()\n     }\n     generateUniqueId() {\n@@ -256,9 +273,11 @@ class Socket extends EventHandler {\n         }\n         return new Promise((resolve, reject) => {\n             me.connectPromises.push(resolve)\n-\n+            console.debug(\"Connecting..\")\n+            \n             const ws = new WebSocket(this.url)\n-            ws.onopen = (event) => {\n+               \n+                ws.onopen = (event) => {\n                 me.ws = ws\n                 me.isConnected = true\n                 me.isConnecting = false\n@@ -269,6 +288,9 @@ class Socket extends EventHandler {\n                     me.onClose()\n \n                 }\n+                ws.onerror = (event)=>{\n+                    me.onClose()\n+                }\n                 me.connectPromises.forEach(resolve => {\n                     resolve(me)\n                 })\n@@ -290,6 +312,7 @@ class Socket extends EventHandler {\n             api.ws.send(JSON.stringify(data))\n         })\n     }\n+\n     async call(method, ...args) {\n         const call = {\n             callId: this.generateUniqueId(),\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 6fffd16..4f702b6 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -125,9 +125,9 @@ message-list {\n   align-items: flex-start;\n   margin-bottom: 15px;\n   padding: 10px;\n   border-radius: 8px;\n-  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);\n }\n \n .chat-messages .message .avatar {\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex c5e3d61..0d008ee 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -22,6 +22,16 @@ class ChatWindowElement extends HTMLElement {\n         \n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n+        let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+          });\n+        \n+        \n         const chatTitle = document.createElement('h2')\n         chatTitle.classList.add(\"chat-title\")\n         chatTitle.innerText = \"Loading...\"\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 05ef008..c2b27ee 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -12,6 +12,8 @@\n   <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"base.css\">\n+  <link rel=\"manifest\" href=\"manifest.json\" />\n+\n </head>\n <body>\n   <header>"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added favicon and manifest for PWA support", "commit": "7d05bd9da45489c02a9b057eef86d45e2ca90049", "diff": "commit 7d05bd9da45489c02a9b057eef86d45e2ca90049\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 20:52:37 2025 +0100\n\n    Favicon.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nnew file mode 100644\nindex 0000000..d2e58f2\n--- /dev/null\n+++ b/src/snek/static/manifest.json\n@@ -0,0 +1,11 @@\n+{\n+    \"name\": \"Snek\",\n+    \"description\": \"Danger noodle\",\n+    \"icons\": [\n+      {\n+        \"src\": \"/image/snek1.png\",\n+        \"type\": \"image/png\",\n+        \"sizes\": \"512x512\"\n+      }\n+    ]\n+  }\n\\ No newline at end of file\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c2b27ee..f001813 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,8 +11,9 @@\n   <script src=\"/message-list-manager.js\"></script>\n   <script src=\"/chat-input.js\"></script>\n   <script src=\"/chat-window.js\"></script>\n-  <link rel=\"stylesheet\" href=\"base.css\">\n-  <link rel=\"manifest\" href=\"manifest.json\" />\n+  <link rel=\"stylesheet\" href=\"/base.css\">\n+  <link rel=\"manifest\" href=\"/manifest.json\" />\n+  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n \n </head>\n <body>"}
{"repo": ".", "date": "2025-01-28", "line": "docs: Added display and start_url to manifest", "commit": "4da635502bca60efd0cc59aa4df236d7b99c2ec2", "diff": "commit 4da635502bca60efd0cc59aa4df236d7b99c2ec2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:01:51 2025 +0100\n\n    Updated manifest.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex d2e58f2..6d98b90 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -1,6 +1,8 @@\n {\n     \"name\": \"Snek\",\n     \"description\": \"Danger noodle\",\n+    \"display\": \"standalone\",\n+    \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n         \"src\": \"/image/snek1.png\","}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Reduced padding on chat messages", "commit": "d69c75c6197e857ad61e4dbc872b5ab5872c4837", "diff": "commit d69c75c6197e857ad61e4dbc872b5ab5872c4837\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:43:48 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4f702b6..9a7eb32 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -109,7 +109,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n-  padding: 20px;\n+  padding: 10px;\n   height: 200px;\n }\n@@ -123,8 +123,8 @@ message-list {\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\n-  margin-bottom: 15px;\n-  padding: 10px;\n+  margin-bottom: 0px;\n+  padding: 5px;\n   border-radius: 8px;"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Add data attributes to message elements", "commit": "9e94210bc3f3b1b614a198591c52f404d84a8be2", "diff": "commit 9e94210bc3f3b1b614a198591c52f404d84a8be2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Jan 28 21:54:53 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex a6fc835..71b6b7c 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -19,7 +19,12 @@ class MessageListElement extends HTMLElement {\n     }\n     createElement(message){\n         const element = document.createElement(\"div\")\n-        \n+        element.dataset.uid = message.uid\n+        element.dataset.channel_uid = message.channel_uid\n+        element.dataset.user_nick = message.user_nick\n+        element.dataset.created_at = message.created_at\n+        element.dataset.user_uid = message.user_uid\n+        element.dataset.message = message.message \n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added user color and updated message display", "commit": "84e5bac1b93d5d1c124d303e6b08a29baaf4977c", "diff": "commit 84e5bac1b93d5d1c124d303e6b08a29baaf4977c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:33:00 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 97070c4..f611d6a 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -12,11 +12,17 @@ class UserModel(BaseModel):\n     )\n     nick = ModelField(\n         name=\"nick\",\n-        required=False,\n+        required=True,\n         min_length=2,\n         max_length=20,\n         regex=r\"^[a-zA-Z0-9_]+$\",\n     )\n+    color = ModelField(\n+        name =\"color\",\n+        required=True,\n+        kind=str\n+    )\n     email = ModelField(\n         name=\"email\",\n         required=False,\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 6a8f76c..97fbaae 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -7,6 +7,7 @@ from snek.service.chat import ChatService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n+from snek.service.util import UtilService\n from snek.system.object import Object\n \n \n@@ -21,6 +22,7 @@ def get_services(app):\n             \"chat\": ChatService(app=app),\n             \"socket\": SocketService(app=app),\n             \"notification\": NotificationService(app=app),\n+            \"util\": UtilService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 74ca94e..fcbf0bc 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -19,6 +19,7 @@ class ChatService(BaseService):\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n             message=message,\n             user_uid=user_uid,\n+            color=user['color'],\n             channel_uid=channel_uid,\n             created_at=channel_message[\"created_at\"], \n             updated_at=None,\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 60825a5..eb14ee7 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -13,11 +13,17 @@ class UserService(BaseService):\n             return False\n         return True\n \n+    async def save(self, user):\n+        if not user['color']:\n+            user['color'] = await self.services.util.random_light_hex_color()\n+        return await super().save(user)\n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n         model[\"nick\"] = username\n+        model['color'] = await self.services.util.random_light_hex_color()\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 71b6b7c..0850a25 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -20,6 +20,7 @@ class MessageListElement extends HTMLElement {\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n+        element.dataset.color = message.color\n         element.dataset.channel_uid = message.channel_uid\n         element.dataset.user_nick = message.user_nick\n         element.dataset.created_at = message.created_at\n@@ -28,11 +29,13 @@ class MessageListElement extends HTMLElement {\n         element.classList.add(\"message\")\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n+        avatar.style.backgroundColor = message.color\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")\n         const author = document.createElement(\"div\")\n         author.classList.add(\"author\")\n+        author.style.color = message.color\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n@@ -60,6 +63,7 @@ class MessageListElement extends HTMLElement {\n             message.channel_uid,\n             message.user_uid,\n             message.user_nick,\n+            message.color,\n             message.message,\n             message.created_at,\n             message.updated_at\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex 6589279..a28262a 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -5,11 +5,13 @@ class MessageModel {\n     created_at = null \n     updated_at = null  \n     element = null \n-    constructor(uid, channel_uid,user_uid,user_nick, message,created_at, updated_at){\n+    color = null\n+    constructor(uid, channel_uid,user_uid,user_nick, color,message,created_at, updated_at){\n         this.uid = uid \n         this.message = message \n         this.user_uid = user_uid \n         this.user_nick = user_nick\n+        this.color = color\n         this.channel_uid = channel_uid  \n         this.created_at = created_at\n         this.updated_at = updated_at\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 9722534..d7e4163 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -48,6 +48,7 @@ class BaseMapper:\n     async def save(self, model: BaseModel) -> bool:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        model.updated_at.update()\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex c9904f5..db5be55 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -16,9 +16,12 @@ class LoginView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            user = await self.services.user.get(username=form.username.value,deleted_at=None)\n+            user = await self.services.user.get(username=form['username'],deleted_at=None)\n+            await self.services.user.save(user)\n             self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = form.username.value\n+            self.session[\"username\"] = user['username']\n             self.session[\"uid\"] = user[\"uid\"]\n+            self.session[\"color\"] = user[\"color\"]\n             return {\"redirect_url\": \"/web.html\"}\n         return {\"is_valid\": False}\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex eb1c8d8..c29d855 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -22,5 +22,5 @@ class RegisterView(BaseFormView):\n         self.request.session[\"uid\"] = result[\"uid\"]\n         self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n-\n+        self.request.session[\"color\"] = result[\"color\"]\n         return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 514db45..d4ed660 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -14,13 +14,19 @@ class RPCView(BaseView):\n             self.user_uid = self.view.session.get(\"uid\")\n             self.ws = ws \n             \n-       \n+        async def get_user(self, user_uid):\n+            if not user_uid:\n+                user_uid = self.user_uid\n+            user = await self.services.user.get(uid=user_uid)\n+            record = user.record\n+            del record['password']\n+            del record['deleted_at']\n+            del record['email']\n+            return record \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 \n-                print(\"JEEEHHH\\n\",flush=True)\n-\n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:\n                     print(\"User not found!\",flush= True)\n@@ -28,6 +34,7 @@ class RPCView(BaseView):\n \n                 messages.insert(0,dict(\n                     uid=message[\"uid\"],\n+                    color=user['color'],\n                     user_uid=message[\"user_uid\"],\n                     channel_uid=message[\"channel_uid\"],\n                     user_nick=user['nick'],\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex a307dee..04ea4d9 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -33,6 +33,7 @@ class StatusView(BaseView):\n                 \"email\": user[\"email\"],\n                 \"nick\": user[\"nick\"],\n                 \"uid\": user[\"uid\"],\n+                \"color\": user['color'],\n                 \"memberships\": memberships,\n             }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added utility service for generating random light hex colors", "commit": "284d38096c7c5b1201f261ec7a5a28ed457952b5", "diff": "commit 284d38096c7c5b1201f261ec7a5a28ed457952b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:33:11 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nnew file mode 100644\nindex 0000000..5550a8c\n--- /dev/null\n+++ b/src/snek/service/util.py\n@@ -0,0 +1,15 @@\n+import random\n+\n+\n+from snek.system.service import BaseService\n+\n+\n+class UtilService(BaseService):\n+    \n+    async def random_light_hex_color(self):\n+   \n+        r = random.randint(128, 255)\n+        g = random.randint(128, 255)\n+        b = random.randint(128, 255)\n+        \n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added avatar color and text color", "commit": "93b2f6cc41f08e21241642976b90e3dd98dc37ec", "diff": "commit 93b2f6cc41f08e21241642976b90e3dd98dc37ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:35:21 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 0850a25..7f3c61b 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -30,6 +30,7 @@ class MessageListElement extends HTMLElement {\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color\n+        avatar.style.color= \"black\"\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added linkify functionality to message text", "commit": "9f652ece1bf0498f9032f94b77becc96b6eff009", "diff": "commit 9f652ece1bf0498f9032f94b77becc96b6eff009\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:46:11 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7f3c61b..e60cda1 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -17,6 +17,14 @@ class MessageListElement extends HTMLElement {\n         this.component = document.createElement('div')\n         this.shadowRoot.appendChild(this.component )\n     }\n+    linkifyText(text) {\n+            const urlRegex = /https?:\\/\\/[^\\s]+/g;\n+        \n+            return text.replace(urlRegex, (url) => {\n+                return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n+            });\n+        \n+    }\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n@@ -40,7 +48,7 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        text.textContent = message.message\n+        text.innerHTML = this.linkifyText(message.message)\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Increased message limit to 60", "commit": "16afbb4e15f370babeedfc2aa917daa0292da5a6", "diff": "commit 16afbb4e15f370babeedfc2aa917daa0292da5a6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:48:21 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex baf2086..a088854 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -59,7 +59,7 @@ class BaseService:\n \n     async def find(self, **kwargs):\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n-            kwargs[\"_limit\"] = 30\n+            kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n             yield model\n \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d4ed660..97d58f4 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -25,7 +25,7 @@ class RPCView(BaseView):\n             return record \n         async def get_messages(self, channel_uid,offset=0):\n             messages = []\n                 \n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide avatar and author until switch-user message", "commit": "41927b7ef439424326cc58e3939f476e04b8eabb", "diff": "commit 41927b7ef439424326cc58e3939f476e04b8eabb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 00:55:17 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9a7eb32..38d3288 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -208,3 +208,30 @@ message-list {\n   }\n }\n \n+.message {\n+  .avatar {\n+      opacity: 0;\n+  }\n+\n+  .author {\n+      display: none;\n+  }\n+\n+  .time {\n+      display: none;\n+  }\n+}\n+.message.switch-user {\n+  .avatar {\n+      opacity: 1;\n+  }\n+  .author {\n+      display: block;\n+  }\n+}\n+\n+.message:has(+ .message.switch-user) {\n+  .time {\n+      display: block;\n+  }\n+}"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and switch-user class for message differentiation", "commit": "75ec590be5fc3f446c97549d90c135966142ac25", "diff": "commit 75ec590be5fc3f446c97549d90c135966142ac25\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 01:04:54 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex e60cda1..8c8cf7f 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -35,6 +35,11 @@ class MessageListElement extends HTMLElement {\n         element.dataset.user_uid = message.user_uid\n         element.dataset.message = message.message \n         element.classList.add(\"message\")\n+        if(!this.messages.length){\n+            element.classList.add(\"switch-user\")\n+        }else if (this.messages[this.messages.length-1].user_uid != message.user_uid){\n+            element.classList.add(\"switch-user\")\n+        }\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Show message time on last message and switch user messages", "commit": "0e821f8b588def99f950fecb9369456cff086e0b", "diff": "commit 0e821f8b588def99f950fecb9369456cff086e0b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 01:06:28 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 38d3288..4b1e603 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -230,8 +230,9 @@ message-list {\n   }\n }\n \n-.message:has(+ .message.switch-user) {\n-  .time {\n-      display: block;\n-  }\n+.message:has(+ .message.switch-user), .message:last-child\n+ {\n+    .time {\n+        display: block;\n+    }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Enforce HTTPS for external URLs", "commit": "931aae5134cad80bf7f5ba87fe215a03761f081b", "diff": "commit 931aae5134cad80bf7f5ba87fe215a03761f081b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:45:18 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 75f800e..8198a37 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -10,7 +10,10 @@ class HTMLFrame extends HTMLElement {\n         this.container.classList.add(\"html_frame\")\n       const url = this.getAttribute('url');\n       if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n+        if(!fullUrl.startsWith(\"https\")){\n+        }\n         if(!url.startsWith(\"/\"))\n             fullUrl.searchParams.set('url', url)   \n         this.loadAndRender(fullUrl.toString());"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added html-frame.js script tag", "commit": "c558dc2d79b90e7424cf4311747f077332b0a193", "diff": "commit c558dc2d79b90e7424cf4311747f077332b0a193\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:47:00 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f001813..8df6e3c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script sr==\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected typo in script source path", "commit": "5f3dac8bc6b702735383688de44ad7609264742a", "diff": "commit 5f3dac8bc6b702735383688de44ad7609264742a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:48:16 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df6e3c..1396ac1 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,7 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n-  <script sr==\"/html-frame.js\"></script>\n+  <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <script src=\"/models.js\"></script>"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Ensure URL is HTTPS and handle relative URLs", "commit": "4442f75ec50d3d27cfae1702459d5f8f34ba415b", "diff": "commit 4442f75ec50d3d27cfae1702459d5f8f34ba415b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:52:53 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 8198a37..be12a09 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -8,12 +8,13 @@ class HTMLFrame extends HTMLElement {\n \n     connectedCallback() {\n         this.container.classList.add(\"html_frame\")\n-      const url = this.getAttribute('url');\n+      let url = this.getAttribute('url');\n+      if(!url.startsWith(\"https\")){\n+      }\n       if (url) {\n         let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!fullUrl.startsWith(\"https\")){\n-        }\n+        \n         if(!url.startsWith(\"/\"))\n             fullUrl.searchParams.set('url', url)   \n         this.loadAndRender(fullUrl.toString());"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added padding and URL handling for HTMLFrame", "commit": "030942db0984ac0f3a4072581d58d81fad03ef91", "diff": "commit 030942db0984ac0f3a4072581d58d81fad03ef91\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 02:53:51 2025 +0100\n\n    New padding.\n\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex be12a09..61369e3 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -10,7 +10,7 @@ class HTMLFrame extends HTMLElement {\n         this.container.classList.add(\"html_frame\")\n       let url = this.getAttribute('url');\n       if(!url.startsWith(\"https\")){\n       }\n       if (url) {\n         let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install prompt and button for PWA installation", "commit": "438fad301447e3265ff7484606f8222b271e4d9d", "diff": "commit 438fad301447e3265ff7484606f8222b271e4d9d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:43:42 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a2d45d4..a125c4e 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -298,6 +298,10 @@ class Socket extends EventHandler {\n         })\n     }\n     onData(data) {\n+        if(data.success != undefined && !data.success){\n+            console.error(data)\n+        }\n+\n         if (data.callId) {\n             this.emit(data.callId, data.data)\n         }\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0d008ee..bdad37e 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -22,14 +22,7 @@ class ChatWindowElement extends HTMLElement {\n         \n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n-        let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n-          });\n+        \n         \n         \n         const chatTitle = document.createElement('h2')\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1396ac1..c9ed40d 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -35,9 +35,28 @@\n+       \n       </ul>\n+      <fancy-button id=\"install-button\" style=\"display:none\" text=\"Install\">Install</fancy-button>\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>\n+  <script>\n+let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+           \n+            const button = document.getElementById(\"install-button\")\n+            button.addEventListener(\"click\", async ()=>{  \n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+            })\n+            button.style.display = 'block'\n+            \n+          });\n+        ;\n+    </script>\n </body>\n </html>\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 97d58f4..af04516 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -4,26 +4,60 @@ from snek.system.view import BaseView\n \n class RPCView(BaseView):\n \n-    login_required = True\n-\n     class RPCApi:\n         def __init__(self,view, ws):\n             self.view = view \n             self.app = self.view.app\n             self.services = self.app.services\n-            self.user_uid = self.view.session.get(\"uid\")\n             self.ws = ws \n+\n+        @property\n+        def user_uid(self):\n+            return self.view.session.get(\"uid\")\n             \n+\n+        @property \n+        def request(self):\n+            return self.view.request \n+        \n+        def _require_login(self):\n+            if not self.is_logged_in:\n+                raise Exception(\"Not logged in\")\n+\n+        @property \n+        def is_logged_in(self):\n+            return self.view.session.get(\"logged_in\", False)\n+\n+        async def login(self, username, password):\n+            success = await self.services.user.validate_login(username, password)\n+            if not success:\n+                raise Exception(\"Invalid username or password\")\n+            user = await self.services.user.get(username=username)\n+            self.view.session[\"uid\"] = user[\"uid\"]\n+            self.view.session[\"logged_in\"] = True\n+            self.view.session[\"username\"] = user[\"username\"]\n+            self.view.session[\"user_nick\"] = user[\"nick\"]\n+            record = user.record\n+            del record['password']\n+            del record['deleted_at']\n+            await self.services.socket.add(self.ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n+       \n+            return record \n         async def get_user(self, user_uid):\n+            self._require_login()\n             if not user_uid:\n                 user_uid = self.user_uid\n             user = await self.services.user.get(uid=user_uid)\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            del record['email']\n+            if not user_uid == user[\"uid\"]:\n+                del record['email']\n             return record \n         async def get_messages(self, channel_uid,offset=0):\n+            self._require_login()\n             messages = []\n                 \n@@ -44,6 +78,7 @@ class RPCView(BaseView):\n             return messages\n         \n         async def get_channels(self):\n+            self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n                 channels.append(dict(\n@@ -55,11 +90,13 @@ class RPCView(BaseView):\n             return channels\n \n         async def send_message(self, room, message):\n+            self._require_login()\n             await self.services.chat.send(self.user_uid,room,message)\n             return True \n         \n \n         async def echo(self,*args):\n+            self._require_login()\n             return args\n \n \n@@ -67,16 +104,21 @@ class RPCView(BaseView):\n \n \n         async def __call__(self, data):\n-            call_id = data.get(\"callId\")\n-            method_name = data.get(\"method\")\n-            args = data.get(\"args\")\n-            if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n-\n-            method = getattr(self,method_name.replace(\".\",\"_\"),None)\n-            result = await method(*args)\n-            await self.ws.send_json({\"callId\":call_id,\"data\":result})\n-\n+            try:\n+                call_id = data.get(\"callId\")\n+                method_name = data.get(\"method\")\n+                if method_name.startswith(\"_\"):\n+                    raise Exception(\"Not allowed\")\n+                args = data.get(\"args\")\n+                if hasattr(super(),method_name) or not hasattr(self,method_name):\n+                    return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+                method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+                if not method:\n+                    raise Exception(\"Method not found\")\n+                result = await method(*args)\n+                await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+            except Exception as ex:\n+                await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n     async def call_ping(self,callId,*args):\n         return {\"pong\": args}\n@@ -87,9 +129,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        await self.services.socket.add(ws)\n-        async for subscription in self.services.channel_member.find(user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-            await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n+        if self.request.session.get(\"logged_in\") is True:\n+            await self.services.socket.add(ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to navigation", "commit": "3e4b6b00620f8cf2c8b8c63918e6c93d2987174d", "diff": "commit 3e4b6b00620f8cf2c8b8c63918e6c93d2987174d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:45:14 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c9ed40d..1b671bc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,7 +22,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">Home</a>\n       <a href=\"/logout.html\">Logout</a>\n     </nav>\n@@ -37,7 +37,6 @@\n        \n       </ul>\n-      <fancy-button id=\"install-button\" style=\"display:none\" text=\"Install\">Install</fancy-button>\n     </aside>\n     <chat-window class=\"chat-area\"></chat-window>\n   </main>"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide install button initially", "commit": "1f5dc57d6f24b17fa66ab5692038e005cc444378", "diff": "commit 1f5dc57d6f24b17fa66ab5692038e005cc444378\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 06:45:26 2025 +0100\n\n    Install button.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1b671bc..1d660f6 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,7 +22,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">Home</a>\n       <a href=\"/logout.html\">Logout</a>\n     </nav>"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render messages with HTML for rich formatting", "commit": "03c72e85f72207a7b2480f881f7b0cb7055c5feb", "diff": "commit 03c72e85f72207a7b2480f881f7b0cb7055c5feb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:30:54 2025 +0100\n\n    Markdown.\n\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0fab568..0bda0bc 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -5,3 +5,4 @@ class ChannelMessageModel(BaseModel):\n     channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     message = ModelField(name=\"message\", required=True, kind=str)\n+    html = ModelField(name=\"html\", required=False, kind=str)\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 4b1a6f8..8765d53 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,14 +1,38 @@\n from snek.system.service import BaseService\n-\n+import jinja2 \n \n class ChannelMessageService(BaseService):\n     mapper_name = \"channel_message\"\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n+        \n         model[\"channel_uid\"] = channel_uid\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n+                \n+        context = {\n+            \n+        }\n+        \n+        \n+        record = model.record \n+        context.update(record)\n+        user = await self.app.services.user.get(uid=user_uid)\n+        context.update(dict(\n+            user_uid=user['uid'],\n+            username=user['username'],\n+            user_nick=user['nick']\n+        ))\n+        try:\n+            template = self.app.jinja2_env.get_template(\"message.html\")\n+            model[\"html\"] = template.render(**context)\n+        except Exception as ex:\n+            print(ex,flush=True)\n+        print(\"RENDER\",flush=True)\n+        print(\"RECORD\",context,flush=True)\n+        \n+        print(\"AFTER RENDER\",flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex fcbf0bc..b31f747 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,7 +17,8 @@ class ChatService(BaseService):\n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n-            message=message,\n+            message=channel_message[\"message\"],\n+            html=channel_message[\"html\"],\n             user_uid=user_uid,\n             color=user['color'],\n             channel_uid=channel_uid,\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 8c8cf7f..01fe8fb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -34,6 +34,7 @@ class MessageListElement extends HTMLElement {\n         element.dataset.created_at = message.created_at\n         element.dataset.user_uid = message.user_uid\n         element.dataset.message = message.message \n+        \n         element.classList.add(\"message\")\n         if(!this.messages.length){\n             element.classList.add(\"switch-user\")\n@@ -53,7 +54,8 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        text.innerHTML = this.linkifyText(message.message)\n+        if(message.html)\n+            text.innerHTML = this.linkifyText(message.html)\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at\n@@ -79,6 +81,7 @@ class MessageListElement extends HTMLElement {\n             message.user_nick,\n             message.color,\n             message.message,\n+            message.html,\n             message.created_at,\n             message.updated_at\n         )\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex a28262a..1c05b42 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -1,14 +1,16 @@\n class MessageModel {\n     message = null \n+    html = null\n     user_uid = null \n     channel_uid = null \n     created_at = null \n     updated_at = null  \n     element = null \n     color = null\n-    constructor(uid, channel_uid,user_uid,user_nick, color,message,created_at, updated_at){\n+    constructor(uid, channel_uid,user_uid,user_nick, color,message,html,created_at, updated_at){\n         this.uid = uid \n         this.message = message \n+        this.html = html \n         this.user_uid = user_uid \n         this.user_nick = user_nick\n         this.color = color\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex af04516..6bbfa20 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -73,7 +73,8 @@ class RPCView(BaseView):\n                     channel_uid=message[\"channel_uid\"],\n                     user_nick=user['nick'],\n                     message=message[\"message\"],\n-                    created_at=message[\"created_at\"]\n+                    created_at=message[\"created_at\"],\n+                    html=message['html'] \n                 ))\n             return messages"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added basic message template with markdown support", "commit": "561a915e30274d8b191678135912313ebccde70f", "diff": "commit 561a915e30274d8b191678135912313ebccde70f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:31:06 2025 +0100\n\n    Message\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nnew file mode 100644\nindex 0000000..d2b8809\n--- /dev/null\n+++ b/src/snek/templates/message.html\n@@ -0,0 +1,10 @@\n+<style>\n+    {{highlight_styles}}\n+</style>\n+    <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n+        {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n+    </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project to snekbak", "commit": "d7c003c4096f8cfed8f4edd517f41d45f4f8b501", "diff": "commit d7c003c4096f8cfed8f4edd517f41d45f4f8b501\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:36:18 2025 +0100\n\n    temporary move\n\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\ndeleted file mode 100644\nindex 50a4245..0000000\n--- a/src/snek/docs/app.py\n+++ /dev/null\n@@ -1,43 +0,0 @@\n-import pathlib\n-\n-from aiohttp import web\n-from app.app import Application as BaseApplication\n-\n-from snek.system.markdown import MarkdownExtension\n-\n-\n-class Application(BaseApplication):\n-\n-    def __init__(self, path=None, *args, **kwargs):\n-        self.path = pathlib.Path(path)\n-        template_path = self.path\n-\n-        super().__init__(template_path=template_path, *args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-\n-        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n-\n-    async def handle_document(self, request):\n-        relative_path = request.match_info[\"tail\"].strip(\"/\")\n-        if relative_path == \"\":\n-            relative_path = \"index.html\"\n-        document_path = self.path.joinpath(relative_path)\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-        if document_path.is_dir():\n-            document_path = document_path.joinpath(\"index.html\")\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-\n-        response = await self.render_template(\n-            str(document_path.relative_to(self.path)), request\n-        )\n-        return response\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\ndeleted file mode 100644\nindex e30a99d..0000000\n--- a/src/snek/docs/docs/api.html\n+++ /dev/null\n@@ -1,61 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n-\n-Currently only some details about the internal API are available.\n-\n-```python\n-\n-new_user_object = await app.service.user.register(\n-    username=\"retoor\", \n-    password=\"retoorded\"\n-)\n-```\n-\n-```python\n-from snek.system import security\n-\n-var1 = security.encrypt(\"data\")\n-var2 = security.encrypt(b\"data\")\n-\n-assert(var1 == var2)\n-```\n-\n-```python\n-from snek.system.view import BaseView \n-\n-class IndexView(BaseView):\n-    \n-    async def get(self):\n-        return await self.render(\"index.html\")\n-```\n-```python\n-from snek.system.view import BaseFormView\n-from snek.form.register import RegisterForm\n-\n-class RegisterFormView(BaseFormView):\n-    \n-    form = RegisterForm\n-```\n-```python\n-app.routes.add_view(\"/your-page.html\", YourViewClass)\n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\ndeleted file mode 100644\nindex 7c53bec..0000000\n--- a/src/snek/docs/docs/base.html\n+++ /dev/null\n@@ -1,116 +0,0 @@\n-<html>\n-\n-<head>\n-    <style>{{ highlight_styles }}</style>\n-    <style>\n-        \n-        * {\n-\n-            box-sizing: border-box;\n-        }\n-\n-        .dialog {\n-\n-            border-radius: 10px;\n-            padding: 30px;\n-            width: 800px;\n-            margin: 30px;\n-            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n-        }\n-\n-        @media screen and (max-width: 500px) {\n-            .center {\n-                width: 100%;\n-                left: 0px;\n-            }\n-\n-            .dialog {\n-                width: 100%;\n-                left: 0px;\n-            }\n-\n-        }\n-\n-        h1 {\n-            font-size: 2em;\n-            margin-bottom: 20px;\n-        }\n-\n-        h2 {\n-            font-size: 1.4em;\n-            margin-bottom: 20px;\n-        }\n-\n-        html,body,main {\n-            font-family: Arial, sans-serif;\n-            line-height: 1.5;\n-            display: flex;\n-            flex-direction: column;\n-            align-items: center;\n-            min-height: 100vh;\n-            width: 100%;\n-        }\n-        article {\n-            max-width: 100%;\n-            width: 60%;\n-            padding: 30px;\n-            min-height: 100vh;\n-            word-break: break-all;\n-        }\n-        footer {\n-            position: fixed;\n-            width: 60%;\n-            text-align: center; \n-            bottom: 0;\n-            left: 20%;\n-        }\n-        a {\n-    display: block;\n-    margin-top: 15px;\n-    font-size: 0.9em;\n-    transition: color 0.3s;\n-  }\n-  header {\n-\n-    text-align: left;\n-    width: 60%;\n-    padding: 30px;\n-  }\n-  header a {\n-    display: inline;\n-\n-  }\n-        div {\n-            text-align: left;\n-\n-        }\n-    </style>\n-</head>\n-\n-<body>\n-    <main>\n-        <header>\n-            <a href=\"/\">Snek</a>\n-            <a href=\"/docs/docs\">Docs</a>\n-        </header>\n-      <article>\n-            {% block main %}\n-            {% endblock %}\n-        </article>\n-    </main>\n-    <footer>\n-        {% markdown %}\n-        {% endmarkdown %}\n-    </footer>\n-</body>\n-\n-</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\ndeleted file mode 100644\nindex c83ee7f..0000000\n--- a/src/snek/docs/docs/form_api_javascript.html\n+++ /dev/null\n@@ -1,17 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n- - generic-form.js \n-\n-It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n-```html \n-<generic-form url=\"/url-to-form-api\"></generic-form>\n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\ndeleted file mode 100644\nindex d7e67bc..0000000\n--- a/src/snek/docs/docs/form_api_python.html\n+++ /dev/null\n@@ -1,92 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n- - `snek.system.form.Form`\n- - `snek.system.form.HTMLElement`\n- - `snek.system.form.FormInputElement`\n- - `snek.system.form.FormButtonElement`\n-\n-Here is an example with custom validation. \n-This example contains a field that checks if user already exists. \n-If invalid, it adds an error message which automatically invalidates the field. \n-Handling of the error messages will automatically done client side.\n-\n-Forms are usaly located in `snek/form/[form name].py`.\n-\n-```python\n-from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n-\n-class UsernameField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.value and await self.app.services.user.count(username=self.value):\n-            result.append(\"Username is not available.\")\n-        return result\n-\n-class RegisterForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Register\")\n-\n-    username = UsernameField(\n-        name=\"username\", \n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\"\n-    )\n-    email = FormInputElement(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        type=\"email\"\n-    )\n-    password = FormInputElement(\n-        name=\"password\",\n-        required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n-        type=\"password\",\n-        place_holder=\"Password\"\n-    )\n-    action = FormButtonElement(\n-        name=\"action\",\n-        value=\"submit\",\n-        text=\"Register\",\n-        type=\"button\"\n-    )\n-```\n-\n-\n-```python \n-data = dict(\n-    username=dict(value=\"retoor\"),\n-    password=dict(value=\"retoorded\")\n-)\n-form.set_user_data(data)\n-\n-is_valid = await form.is_valid\n-\n-key_value_values = await form.record \n-```\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\ndeleted file mode 100644\nindex 8ced8ab..0000000\n--- a/src/snek/docs/docs/index.html\n+++ /dev/null\n@@ -1,37 +0,0 @@\n-{% extends \"docs/base.html\" %}\n-\n-{% block main %}\n-{% markdown %}\n-\n-\n-Snek is a high customizable chat application. \n-It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n-\n-This documentation is under construction. Only the form API and the small introduction is a bit documented.\n-\n-[Small introduction / cheatsheet](/docs/docs/api.html)\n-\n-With the view classes of Snek you can render HTML and Markdown \n-\n-Snek's database model is based on Python dataset library. \n-Snek uses a model/mapper architecture build on top of that library.\n-\n-Snek does have his own components for creating and rendering forms. \n-All forms are made server side and client side is generated client side using a HTML component.\n-It's client side only one line to include a form that can validate and submit.\n-Validation is server side using REST. Page won't refresh.\n-[API Python](/docs/docs/form_api_python.html) \n-[API Javascript](/docs/docs/form_api_javascript.html)\n-\n-\n-{% endmarkdown %}\n-{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snekbak/app.py\nsimilarity index 100%\nrename from src/snek/app.py\nrename to src/snekbak/app.py\ndiff --git a/src/snek/form/__init__.py b/src/snekbak/form/__init__.py\nsimilarity index 100%\nrename from src/snek/form/__init__.py\nrename to src/snekbak/form/__init__.py\ndiff --git a/src/snek/form/login.py b/src/snekbak/form/login.py\nsimilarity index 100%\nrename from src/snek/form/login.py\nrename to src/snekbak/form/login.py\ndiff --git a/src/snek/form/register.py b/src/snekbak/form/register.py\nsimilarity index 100%\nrename from src/snek/form/register.py\nrename to src/snekbak/form/register.py\ndiff --git a/src/snek/gunicorn.py b/src/snekbak/gunicorn.py\nsimilarity index 100%\nrename from src/snek/gunicorn.py\nrename to src/snekbak/gunicorn.py\ndiff --git a/src/snek/mapper/__init__.py b/src/snekbak/mapper/__init__.py\nsimilarity index 100%\nrename from src/snek/mapper/__init__.py\nrename to src/snekbak/mapper/__init__.py\ndiff --git a/src/snek/mapper/channel.py b/src/snekbak/mapper/channel.py\nsimilarity index 100%\nrename from src/snek/mapper/channel.py\nrename to src/snekbak/mapper/channel.py\ndiff --git a/src/snek/mapper/channel_member.py b/src/snekbak/mapper/channel_member.py\nsimilarity index 100%\nrename from src/snek/mapper/channel_member.py\nrename to src/snekbak/mapper/channel_member.py\ndiff --git a/src/snek/mapper/channel_message.py b/src/snekbak/mapper/channel_message.py\nsimilarity index 100%\nrename from src/snek/mapper/channel_message.py\nrename to src/snekbak/mapper/channel_message.py\ndiff --git a/src/snek/mapper/notification.py b/src/snekbak/mapper/notification.py\nsimilarity index 100%\nrename from src/snek/mapper/notification.py\nrename to src/snekbak/mapper/notification.py\ndiff --git a/src/snek/mapper/user.py b/src/snekbak/mapper/user.py\nsimilarity index 100%\nrename from src/snek/mapper/user.py\nrename to src/snekbak/mapper/user.py\ndiff --git a/src/snek/model/__init__.py b/src/snekbak/model/__init__.py\nsimilarity index 100%\nrename from src/snek/model/__init__.py\nrename to src/snekbak/model/__init__.py\ndiff --git a/src/snek/model/channel.py b/src/snekbak/model/channel.py\nsimilarity index 100%\nrename from src/snek/model/channel.py\nrename to src/snekbak/model/channel.py\ndiff --git a/src/snek/model/channel_member.py b/src/snekbak/model/channel_member.py\nsimilarity index 100%\nrename from src/snek/model/channel_member.py\nrename to src/snekbak/model/channel_member.py\ndiff --git a/src/snek/model/channel_message.py b/src/snekbak/model/channel_message.py\nsimilarity index 100%\nrename from src/snek/model/channel_message.py\nrename to src/snekbak/model/channel_message.py\ndiff --git a/src/snek/model/notification.py b/src/snekbak/model/notification.py\nsimilarity index 100%\nrename from src/snek/model/notification.py\nrename to src/snekbak/model/notification.py\ndiff --git a/src/snek/model/user.py b/src/snekbak/model/user.py\nsimilarity index 100%\nrename from src/snek/model/user.py\nrename to src/snekbak/model/user.py\ndiff --git a/src/snek/service/__init__.py b/src/snekbak/service/__init__.py\nsimilarity index 100%\nrename from src/snek/service/__init__.py\nrename to src/snekbak/service/__init__.py\ndiff --git a/src/snek/service/channel.py b/src/snekbak/service/channel.py\nsimilarity index 100%\nrename from src/snek/service/channel.py\nrename to src/snekbak/service/channel.py\ndiff --git a/src/snek/service/channel_member.py b/src/snekbak/service/channel_member.py\nsimilarity index 100%\nrename from src/snek/service/channel_member.py\nrename to src/snekbak/service/channel_member.py\ndiff --git a/src/snek/service/channel_message.py b/src/snekbak/service/channel_message.py\nsimilarity index 100%\nrename from src/snek/service/channel_message.py\nrename to src/snekbak/service/channel_message.py\ndiff --git a/src/snek/service/chat.py b/src/snekbak/service/chat.py\nsimilarity index 100%\nrename from src/snek/service/chat.py\nrename to src/snekbak/service/chat.py\ndiff --git a/src/snek/service/notification.py b/src/snekbak/service/notification.py\nsimilarity index 100%\nrename from src/snek/service/notification.py\nrename to src/snekbak/service/notification.py\ndiff --git a/src/snek/service/socket.py b/src/snekbak/service/socket.py\nsimilarity index 100%\nrename from src/snek/service/socket.py\nrename to src/snekbak/service/socket.py\ndiff --git a/src/snek/service/user.py b/src/snekbak/service/user.py\nsimilarity index 100%\nrename from src/snek/service/user.py\nrename to src/snekbak/service/user.py\ndiff --git a/src/snek/service/util.py b/src/snekbak/service/util.py\nsimilarity index 100%\nrename from src/snek/service/util.py\nrename to src/snekbak/service/util.py\ndiff --git a/src/snek/static/app.js b/src/snekbak/static/app.js\nsimilarity index 100%\nrename from src/snek/static/app.js\nrename to src/snekbak/static/app.js\ndiff --git a/src/snek/static/audio/soundfx.d_alarm1.mp3 b/src/snekbak/static/audio/soundfx.d_alarm1.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_alarm1.mp3\nrename to src/snekbak/static/audio/soundfx.d_alarm1.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_alarm2.mp3 b/src/snekbak/static/audio/soundfx.d_alarm2.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_alarm2.mp3\nrename to src/snekbak/static/audio/soundfx.d_alarm2.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep1.mp3 b/src/snekbak/static/audio/soundfx.d_beep1.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep1.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep1.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep2.mp3 b/src/snekbak/static/audio/soundfx.d_beep2.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep2.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep2.mp3\ndiff --git a/src/snek/static/audio/soundfx.d_beep3.mp3 b/src/snekbak/static/audio/soundfx.d_beep3.mp3\nsimilarity index 100%\nrename from src/snek/static/audio/soundfx.d_beep3.mp3\nrename to src/snekbak/static/audio/soundfx.d_beep3.mp3\ndiff --git a/src/snek/static/base.css b/src/snekbak/static/base.css\nsimilarity index 100%\nrename from src/snek/static/base.css\nrename to src/snekbak/static/base.css\ndiff --git a/src/snek/static/chat-input.js b/src/snekbak/static/chat-input.js\nsimilarity index 100%\nrename from src/snek/static/chat-input.js\nrename to src/snekbak/static/chat-input.js\ndiff --git a/src/snek/static/chat-window.js b/src/snekbak/static/chat-window.js\nsimilarity index 100%\nrename from src/snek/static/chat-window.js\nrename to src/snekbak/static/chat-window.js\ndiff --git a/src/snek/static/fancy-button.js b/src/snekbak/static/fancy-button.js\nsimilarity index 100%\nrename from src/snek/static/fancy-button.js\nrename to src/snekbak/static/fancy-button.js\ndiff --git a/src/snek/static/generic-form.css b/src/snekbak/static/generic-form.css\nsimilarity index 100%\nrename from src/snek/static/generic-form.css\nrename to src/snekbak/static/generic-form.css\ndiff --git a/src/snek/static/generic-form.js b/src/snekbak/static/generic-form.js\nsimilarity index 100%\nrename from src/snek/static/generic-form.js\nrename to src/snekbak/static/generic-form.js\ndiff --git a/src/snek/static/html-frame.css b/src/snekbak/static/html-frame.css\nsimilarity index 100%\nrename from src/snek/static/html-frame.css\nrename to src/snekbak/static/html-frame.css\ndiff --git a/src/snek/static/html-frame.js b/src/snekbak/static/html-frame.js\nsimilarity index 100%\nrename from src/snek/static/html-frame.js\nrename to src/snekbak/static/html-frame.js\ndiff --git a/src/snek/static/manifest.json b/src/snekbak/static/manifest.json\nsimilarity index 100%\nrename from src/snek/static/manifest.json\nrename to src/snekbak/static/manifest.json\ndiff --git a/src/snek/static/markdown-frame.js b/src/snekbak/static/markdown-frame.js\nsimilarity index 100%\nrename from src/snek/static/markdown-frame.js\nrename to src/snekbak/static/markdown-frame.js\ndiff --git a/src/snek/static/message-list-manager.js b/src/snekbak/static/message-list-manager.js\nsimilarity index 100%\nrename from src/snek/static/message-list-manager.js\nrename to src/snekbak/static/message-list-manager.js\ndiff --git a/src/snek/static/message-list.js b/src/snekbak/static/message-list.js\nsimilarity index 100%\nrename from src/snek/static/message-list.js\nrename to src/snekbak/static/message-list.js\ndiff --git a/src/snek/static/models.js b/src/snekbak/static/models.js\nsimilarity index 100%\nrename from src/snek/static/models.js\nrename to src/snekbak/static/models.js\ndiff --git a/src/snek/static/register__.css b/src/snekbak/static/register__.css\nsimilarity index 100%\nrename from src/snek/static/register__.css\nrename to src/snekbak/static/register__.css\ndiff --git a/src/snek/static/schedule.js b/src/snekbak/static/schedule.js\nsimilarity index 100%\nrename from src/snek/static/schedule.js\nrename to src/snekbak/static/schedule.js\ndiff --git a/src/snek/static/style.css b/src/snekbak/static/style.css\nsimilarity index 100%\nrename from src/snek/static/style.css\nrename to src/snekbak/static/style.css\ndiff --git a/src/snek/system/__init__.py b/src/snekbak/system/__init__.py\nsimilarity index 100%\nrename from src/snek/system/__init__.py\nrename to src/snekbak/system/__init__.py\ndiff --git a/src/snek/system/api.py b/src/snekbak/system/api.py\nsimilarity index 100%\nrename from src/snek/system/api.py\nrename to src/snekbak/system/api.py\ndiff --git a/src/snek/system/cache.py b/src/snekbak/system/cache.py\nsimilarity index 100%\nrename from src/snek/system/cache.py\nrename to src/snekbak/system/cache.py\ndiff --git a/src/snek/system/form.py b/src/snekbak/system/form.py\nsimilarity index 100%\nrename from src/snek/system/form.py\nrename to src/snekbak/system/form.py\ndiff --git a/src/snek/system/http.py b/src/snekbak/system/http.py\nsimilarity index 100%\nrename from src/snek/system/http.py\nrename to src/snekbak/system/http.py\ndiff --git a/src/snek/system/mapper.py b/src/snekbak/system/mapper.py\nsimilarity index 100%\nrename from src/snek/system/mapper.py\nrename to src/snekbak/system/mapper.py\ndiff --git a/src/snek/system/markdown.py b/src/snekbak/system/markdown.py\nsimilarity index 100%\nrename from src/snek/system/markdown.py\nrename to src/snekbak/system/markdown.py\ndiff --git a/src/snek/system/middleware.py b/src/snekbak/system/middleware.py\nsimilarity index 100%\nrename from src/snek/system/middleware.py\nrename to src/snekbak/system/middleware.py\ndiff --git a/src/snek/system/model.py b/src/snekbak/system/model.py\nsimilarity index 100%\nrename from src/snek/system/model.py\nrename to src/snekbak/system/model.py\ndiff --git a/src/snek/system/object.py b/src/snekbak/system/object.py\nsimilarity index 100%\nrename from src/snek/system/object.py\nrename to src/snekbak/system/object.py\ndiff --git a/src/snek/system/security.py b/src/snekbak/system/security.py\nsimilarity index 100%\nrename from src/snek/system/security.py\nrename to src/snekbak/system/security.py\ndiff --git a/src/snek/system/service.py b/src/snekbak/system/service.py\nsimilarity index 100%\nrename from src/snek/system/service.py\nrename to src/snekbak/system/service.py\ndiff --git a/src/snek/system/view.py b/src/snekbak/system/view.py\nsimilarity index 100%\nrename from src/snek/system/view.py\nrename to src/snekbak/system/view.py\ndiff --git a/src/snek/templates/about.html b/src/snekbak/templates/about.html\nsimilarity index 100%\nrename from src/snek/templates/about.html\nrename to src/snekbak/templates/about.html\ndiff --git a/src/snek/templates/about.md b/src/snekbak/templates/about.md\nsimilarity index 100%\nrename from src/snek/templates/about.md\nrename to src/snekbak/templates/about.md\ndiff --git a/src/snek/templates/base.html b/src/snekbak/templates/base.html\nsimilarity index 100%\nrename from src/snek/templates/base.html\nrename to src/snekbak/templates/base.html\ndiff --git a/src/snek/templates/base_chat.html b/src/snekbak/templates/base_chat.html\nsimilarity index 100%\nrename from src/snek/templates/base_chat.html\nrename to src/snekbak/templates/base_chat.html\ndiff --git a/src/snek/templates/docs.html b/src/snekbak/templates/docs.html\nsimilarity index 100%\nrename from src/snek/templates/docs.html\nrename to src/snekbak/templates/docs.html\ndiff --git a/src/snek/templates/docs.md b/src/snekbak/templates/docs.md\nsimilarity index 100%\nrename from src/snek/templates/docs.md\nrename to src/snekbak/templates/docs.md\ndiff --git a/src/snek/templates/index.html b/src/snekbak/templates/index.html\nsimilarity index 100%\nrename from src/snek/templates/index.html\nrename to src/snekbak/templates/index.html\ndiff --git a/src/snek/templates/login.html b/src/snekbak/templates/login.html\nsimilarity index 100%\nrename from src/snek/templates/login.html\nrename to src/snekbak/templates/login.html\ndiff --git a/src/snek/templates/message.html b/src/snekbak/templates/message.html\nsimilarity index 100%\nrename from src/snek/templates/message.html\nrename to src/snekbak/templates/message.html\ndiff --git a/src/snek/templates/register.html b/src/snekbak/templates/register.html\nsimilarity index 100%\nrename from src/snek/templates/register.html\nrename to src/snekbak/templates/register.html\ndiff --git a/src/snek/templates/test2.html b/src/snekbak/templates/test2.html\nsimilarity index 100%\nrename from src/snek/templates/test2.html\nrename to src/snekbak/templates/test2.html\ndiff --git a/src/snek/templates/web.html b/src/snekbak/templates/web.html\nsimilarity index 100%\nrename from src/snek/templates/web.html\nrename to src/snekbak/templates/web.html\ndiff --git a/src/snek/view/__init__.py b/src/snekbak/view/__init__.py\nsimilarity index 100%\nrename from src/snek/view/__init__.py\nrename to src/snekbak/view/__init__.py\ndiff --git a/src/snek/view/about.py b/src/snekbak/view/about.py\nsimilarity index 100%\nrename from src/snek/view/about.py\nrename to src/snekbak/view/about.py\ndiff --git a/src/snek/view/docs.py b/src/snekbak/view/docs.py\nsimilarity index 100%\nrename from src/snek/view/docs.py\nrename to src/snekbak/view/docs.py\ndiff --git a/src/snek/view/index.py b/src/snekbak/view/index.py\nsimilarity index 100%\nrename from src/snek/view/index.py\nrename to src/snekbak/view/index.py\ndiff --git a/src/snek/view/login.py b/src/snekbak/view/login.py\nsimilarity index 100%\nrename from src/snek/view/login.py\nrename to src/snekbak/view/login.py\ndiff --git a/src/snek/view/login_form.py b/src/snekbak/view/login_form.py\nsimilarity index 100%\nrename from src/snek/view/login_form.py\nrename to src/snekbak/view/login_form.py\ndiff --git a/src/snek/view/logout.py b/src/snekbak/view/logout.py\nsimilarity index 100%\nrename from src/snek/view/logout.py\nrename to src/snekbak/view/logout.py\ndiff --git a/src/snek/view/register.py b/src/snekbak/view/register.py\nsimilarity index 100%\nrename from src/snek/view/register.py\nrename to src/snekbak/view/register.py\ndiff --git a/src/snek/view/register_form.py b/src/snekbak/view/register_form.py\nsimilarity index 100%\nrename from src/snek/view/register_form.py\nrename to src/snekbak/view/register_form.py\ndiff --git a/src/snek/view/rpc.py b/src/snekbak/view/rpc.py\nsimilarity index 100%\nrename from src/snek/view/rpc.py\nrename to src/snekbak/view/rpc.py\ndiff --git a/src/snek/view/status.py b/src/snekbak/view/status.py\nsimilarity index 100%\nrename from src/snek/view/status.py\nrename to src/snekbak/view/status.py\ndiff --git a/src/snek/view/web.py b/src/snekbak/view/web.py\nsimilarity index 100%\nrename from src/snek/view/web.py\nrename to src/snekbak/view/web.py"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project from snekbak to snek", "commit": "f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98", "diff": "commit f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:37:16 2025 +0100\n\n    Move back\n\ndiff --git a/src/snekbak/app.py b/src/snek/app.py\nsimilarity index 100%\nrename from src/snekbak/app.py\nrename to src/snek/app.py\ndiff --git a/src/snekbak/form/__init__.py b/src/snek/form/__init__.py\nsimilarity index 100%\nrename from src/snekbak/form/__init__.py\nrename to src/snek/form/__init__.py\ndiff --git a/src/snekbak/form/login.py b/src/snek/form/login.py\nsimilarity index 100%\nrename from src/snekbak/form/login.py\nrename to src/snek/form/login.py\ndiff --git a/src/snekbak/form/register.py b/src/snek/form/register.py\nsimilarity index 100%\nrename from src/snekbak/form/register.py\nrename to src/snek/form/register.py\ndiff --git a/src/snekbak/gunicorn.py b/src/snek/gunicorn.py\nsimilarity index 100%\nrename from src/snekbak/gunicorn.py\nrename to src/snek/gunicorn.py\ndiff --git a/src/snekbak/mapper/__init__.py b/src/snek/mapper/__init__.py\nsimilarity index 100%\nrename from src/snekbak/mapper/__init__.py\nrename to src/snek/mapper/__init__.py\ndiff --git a/src/snekbak/mapper/channel.py b/src/snek/mapper/channel.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel.py\nrename to src/snek/mapper/channel.py\ndiff --git a/src/snekbak/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel_member.py\nrename to src/snek/mapper/channel_member.py\ndiff --git a/src/snekbak/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/mapper/channel_message.py\nrename to src/snek/mapper/channel_message.py\ndiff --git a/src/snekbak/mapper/notification.py b/src/snek/mapper/notification.py\nsimilarity index 100%\nrename from src/snekbak/mapper/notification.py\nrename to src/snek/mapper/notification.py\ndiff --git a/src/snekbak/mapper/user.py b/src/snek/mapper/user.py\nsimilarity index 100%\nrename from src/snekbak/mapper/user.py\nrename to src/snek/mapper/user.py\ndiff --git a/src/snekbak/model/__init__.py b/src/snek/model/__init__.py\nsimilarity index 100%\nrename from src/snekbak/model/__init__.py\nrename to src/snek/model/__init__.py\ndiff --git a/src/snekbak/model/channel.py b/src/snek/model/channel.py\nsimilarity index 100%\nrename from src/snekbak/model/channel.py\nrename to src/snek/model/channel.py\ndiff --git a/src/snekbak/model/channel_member.py b/src/snek/model/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/model/channel_member.py\nrename to src/snek/model/channel_member.py\ndiff --git a/src/snekbak/model/channel_message.py b/src/snek/model/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/model/channel_message.py\nrename to src/snek/model/channel_message.py\ndiff --git a/src/snekbak/model/notification.py b/src/snek/model/notification.py\nsimilarity index 100%\nrename from src/snekbak/model/notification.py\nrename to src/snek/model/notification.py\ndiff --git a/src/snekbak/model/user.py b/src/snek/model/user.py\nsimilarity index 100%\nrename from src/snekbak/model/user.py\nrename to src/snek/model/user.py\ndiff --git a/src/snekbak/service/__init__.py b/src/snek/service/__init__.py\nsimilarity index 100%\nrename from src/snekbak/service/__init__.py\nrename to src/snek/service/__init__.py\ndiff --git a/src/snekbak/service/channel.py b/src/snek/service/channel.py\nsimilarity index 100%\nrename from src/snekbak/service/channel.py\nrename to src/snek/service/channel.py\ndiff --git a/src/snekbak/service/channel_member.py b/src/snek/service/channel_member.py\nsimilarity index 100%\nrename from src/snekbak/service/channel_member.py\nrename to src/snek/service/channel_member.py\ndiff --git a/src/snekbak/service/channel_message.py b/src/snek/service/channel_message.py\nsimilarity index 100%\nrename from src/snekbak/service/channel_message.py\nrename to src/snek/service/channel_message.py\ndiff --git a/src/snekbak/service/chat.py b/src/snek/service/chat.py\nsimilarity index 100%\nrename from src/snekbak/service/chat.py\nrename to src/snek/service/chat.py\ndiff --git a/src/snekbak/service/notification.py b/src/snek/service/notification.py\nsimilarity index 100%\nrename from src/snekbak/service/notification.py\nrename to src/snek/service/notification.py\ndiff --git a/src/snekbak/service/socket.py b/src/snek/service/socket.py\nsimilarity index 100%\nrename from src/snekbak/service/socket.py\nrename to src/snek/service/socket.py\ndiff --git a/src/snekbak/service/user.py b/src/snek/service/user.py\nsimilarity index 100%\nrename from src/snekbak/service/user.py\nrename to src/snek/service/user.py\ndiff --git a/src/snekbak/service/util.py b/src/snek/service/util.py\nsimilarity index 100%\nrename from src/snekbak/service/util.py\nrename to src/snek/service/util.py\ndiff --git a/src/snekbak/static/app.js b/src/snek/static/app.js\nsimilarity index 100%\nrename from src/snekbak/static/app.js\nrename to src/snek/static/app.js\ndiff --git a/src/snekbak/static/audio/soundfx.d_alarm1.mp3 b/src/snek/static/audio/soundfx.d_alarm1.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_alarm1.mp3\nrename to src/snek/static/audio/soundfx.d_alarm1.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_alarm2.mp3 b/src/snek/static/audio/soundfx.d_alarm2.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_alarm2.mp3\nrename to src/snek/static/audio/soundfx.d_alarm2.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep1.mp3 b/src/snek/static/audio/soundfx.d_beep1.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep1.mp3\nrename to src/snek/static/audio/soundfx.d_beep1.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep2.mp3 b/src/snek/static/audio/soundfx.d_beep2.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep2.mp3\nrename to src/snek/static/audio/soundfx.d_beep2.mp3\ndiff --git a/src/snekbak/static/audio/soundfx.d_beep3.mp3 b/src/snek/static/audio/soundfx.d_beep3.mp3\nsimilarity index 100%\nrename from src/snekbak/static/audio/soundfx.d_beep3.mp3\nrename to src/snek/static/audio/soundfx.d_beep3.mp3\ndiff --git a/src/snekbak/static/base.css b/src/snek/static/base.css\nsimilarity index 100%\nrename from src/snekbak/static/base.css\nrename to src/snek/static/base.css\ndiff --git a/src/snekbak/static/chat-input.js b/src/snek/static/chat-input.js\nsimilarity index 100%\nrename from src/snekbak/static/chat-input.js\nrename to src/snek/static/chat-input.js\ndiff --git a/src/snekbak/static/chat-window.js b/src/snek/static/chat-window.js\nsimilarity index 100%\nrename from src/snekbak/static/chat-window.js\nrename to src/snek/static/chat-window.js\ndiff --git a/src/snekbak/static/fancy-button.js b/src/snek/static/fancy-button.js\nsimilarity index 100%\nrename from src/snekbak/static/fancy-button.js\nrename to src/snek/static/fancy-button.js\ndiff --git a/src/snekbak/static/generic-form.css b/src/snek/static/generic-form.css\nsimilarity index 100%\nrename from src/snekbak/static/generic-form.css\nrename to src/snek/static/generic-form.css\ndiff --git a/src/snekbak/static/generic-form.js b/src/snek/static/generic-form.js\nsimilarity index 100%\nrename from src/snekbak/static/generic-form.js\nrename to src/snek/static/generic-form.js\ndiff --git a/src/snekbak/static/html-frame.css b/src/snek/static/html-frame.css\nsimilarity index 100%\nrename from src/snekbak/static/html-frame.css\nrename to src/snek/static/html-frame.css\ndiff --git a/src/snekbak/static/html-frame.js b/src/snek/static/html-frame.js\nsimilarity index 100%\nrename from src/snekbak/static/html-frame.js\nrename to src/snek/static/html-frame.js\ndiff --git a/src/snekbak/static/manifest.json b/src/snek/static/manifest.json\nsimilarity index 100%\nrename from src/snekbak/static/manifest.json\nrename to src/snek/static/manifest.json\ndiff --git a/src/snekbak/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nsimilarity index 100%\nrename from src/snekbak/static/markdown-frame.js\nrename to src/snek/static/markdown-frame.js\ndiff --git a/src/snekbak/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nsimilarity index 100%\nrename from src/snekbak/static/message-list-manager.js\nrename to src/snek/static/message-list-manager.js\ndiff --git a/src/snekbak/static/message-list.js b/src/snek/static/message-list.js\nsimilarity index 100%\nrename from src/snekbak/static/message-list.js\nrename to src/snek/static/message-list.js\ndiff --git a/src/snekbak/static/models.js b/src/snek/static/models.js\nsimilarity index 100%\nrename from src/snekbak/static/models.js\nrename to src/snek/static/models.js\ndiff --git a/src/snekbak/static/register__.css b/src/snek/static/register__.css\nsimilarity index 100%\nrename from src/snekbak/static/register__.css\nrename to src/snek/static/register__.css\ndiff --git a/src/snekbak/static/schedule.js b/src/snek/static/schedule.js\nsimilarity index 100%\nrename from src/snekbak/static/schedule.js\nrename to src/snek/static/schedule.js\ndiff --git a/src/snekbak/static/style.css b/src/snek/static/style.css\nsimilarity index 100%\nrename from src/snekbak/static/style.css\nrename to src/snek/static/style.css\ndiff --git a/src/snekbak/system/__init__.py b/src/snek/system/__init__.py\nsimilarity index 100%\nrename from src/snekbak/system/__init__.py\nrename to src/snek/system/__init__.py\ndiff --git a/src/snekbak/system/api.py b/src/snek/system/api.py\nsimilarity index 100%\nrename from src/snekbak/system/api.py\nrename to src/snek/system/api.py\ndiff --git a/src/snekbak/system/cache.py b/src/snek/system/cache.py\nsimilarity index 100%\nrename from src/snekbak/system/cache.py\nrename to src/snek/system/cache.py\ndiff --git a/src/snekbak/system/form.py b/src/snek/system/form.py\nsimilarity index 100%\nrename from src/snekbak/system/form.py\nrename to src/snek/system/form.py\ndiff --git a/src/snekbak/system/http.py b/src/snek/system/http.py\nsimilarity index 100%\nrename from src/snekbak/system/http.py\nrename to src/snek/system/http.py\ndiff --git a/src/snekbak/system/mapper.py b/src/snek/system/mapper.py\nsimilarity index 100%\nrename from src/snekbak/system/mapper.py\nrename to src/snek/system/mapper.py\ndiff --git a/src/snekbak/system/markdown.py b/src/snek/system/markdown.py\nsimilarity index 100%\nrename from src/snekbak/system/markdown.py\nrename to src/snek/system/markdown.py\ndiff --git a/src/snekbak/system/middleware.py b/src/snek/system/middleware.py\nsimilarity index 100%\nrename from src/snekbak/system/middleware.py\nrename to src/snek/system/middleware.py\ndiff --git a/src/snekbak/system/model.py b/src/snek/system/model.py\nsimilarity index 100%\nrename from src/snekbak/system/model.py\nrename to src/snek/system/model.py\ndiff --git a/src/snekbak/system/object.py b/src/snek/system/object.py\nsimilarity index 100%\nrename from src/snekbak/system/object.py\nrename to src/snek/system/object.py\ndiff --git a/src/snekbak/system/security.py b/src/snek/system/security.py\nsimilarity index 100%\nrename from src/snekbak/system/security.py\nrename to src/snek/system/security.py\ndiff --git a/src/snekbak/system/service.py b/src/snek/system/service.py\nsimilarity index 100%\nrename from src/snekbak/system/service.py\nrename to src/snek/system/service.py\ndiff --git a/src/snekbak/system/view.py b/src/snek/system/view.py\nsimilarity index 100%\nrename from src/snekbak/system/view.py\nrename to src/snek/system/view.py\ndiff --git a/src/snekbak/templates/about.html b/src/snek/templates/about.html\nsimilarity index 100%\nrename from src/snekbak/templates/about.html\nrename to src/snek/templates/about.html\ndiff --git a/src/snekbak/templates/about.md b/src/snek/templates/about.md\nsimilarity index 100%\nrename from src/snekbak/templates/about.md\nrename to src/snek/templates/about.md\ndiff --git a/src/snekbak/templates/base.html b/src/snek/templates/base.html\nsimilarity index 100%\nrename from src/snekbak/templates/base.html\nrename to src/snek/templates/base.html\ndiff --git a/src/snekbak/templates/base_chat.html b/src/snek/templates/base_chat.html\nsimilarity index 100%\nrename from src/snekbak/templates/base_chat.html\nrename to src/snek/templates/base_chat.html\ndiff --git a/src/snekbak/templates/docs.html b/src/snek/templates/docs.html\nsimilarity index 100%\nrename from src/snekbak/templates/docs.html\nrename to src/snek/templates/docs.html\ndiff --git a/src/snekbak/templates/docs.md b/src/snek/templates/docs.md\nsimilarity index 100%\nrename from src/snekbak/templates/docs.md\nrename to src/snek/templates/docs.md\ndiff --git a/src/snekbak/templates/index.html b/src/snek/templates/index.html\nsimilarity index 100%\nrename from src/snekbak/templates/index.html\nrename to src/snek/templates/index.html\ndiff --git a/src/snekbak/templates/login.html b/src/snek/templates/login.html\nsimilarity index 100%\nrename from src/snekbak/templates/login.html\nrename to src/snek/templates/login.html\ndiff --git a/src/snekbak/templates/message.html b/src/snek/templates/message.html\nsimilarity index 100%\nrename from src/snekbak/templates/message.html\nrename to src/snek/templates/message.html\ndiff --git a/src/snekbak/templates/register.html b/src/snek/templates/register.html\nsimilarity index 100%\nrename from src/snekbak/templates/register.html\nrename to src/snek/templates/register.html\ndiff --git a/src/snekbak/templates/test2.html b/src/snek/templates/test2.html\nsimilarity index 100%\nrename from src/snekbak/templates/test2.html\nrename to src/snek/templates/test2.html\ndiff --git a/src/snekbak/templates/web.html b/src/snek/templates/web.html\nsimilarity index 100%\nrename from src/snekbak/templates/web.html\nrename to src/snek/templates/web.html\ndiff --git a/src/snekbak/view/__init__.py b/src/snek/view/__init__.py\nsimilarity index 100%\nrename from src/snekbak/view/__init__.py\nrename to src/snek/view/__init__.py\ndiff --git a/src/snekbak/view/about.py b/src/snek/view/about.py\nsimilarity index 100%\nrename from src/snekbak/view/about.py\nrename to src/snek/view/about.py\ndiff --git a/src/snekbak/view/docs.py b/src/snek/view/docs.py\nsimilarity index 100%\nrename from src/snekbak/view/docs.py\nrename to src/snek/view/docs.py\ndiff --git a/src/snekbak/view/index.py b/src/snek/view/index.py\nsimilarity index 100%\nrename from src/snekbak/view/index.py\nrename to src/snek/view/index.py\ndiff --git a/src/snekbak/view/login.py b/src/snek/view/login.py\nsimilarity index 100%\nrename from src/snekbak/view/login.py\nrename to src/snek/view/login.py\ndiff --git a/src/snekbak/view/login_form.py b/src/snek/view/login_form.py\nsimilarity index 100%\nrename from src/snekbak/view/login_form.py\nrename to src/snek/view/login_form.py\ndiff --git a/src/snekbak/view/logout.py b/src/snek/view/logout.py\nsimilarity index 100%\nrename from src/snekbak/view/logout.py\nrename to src/snek/view/logout.py\ndiff --git a/src/snekbak/view/register.py b/src/snek/view/register.py\nsimilarity index 100%\nrename from src/snekbak/view/register.py\nrename to src/snek/view/register.py\ndiff --git a/src/snekbak/view/register_form.py b/src/snek/view/register_form.py\nsimilarity index 100%\nrename from src/snekbak/view/register_form.py\nrename to src/snek/view/register_form.py\ndiff --git a/src/snekbak/view/rpc.py b/src/snek/view/rpc.py\nsimilarity index 100%\nrename from src/snekbak/view/rpc.py\nrename to src/snek/view/rpc.py\ndiff --git a/src/snekbak/view/status.py b/src/snek/view/status.py\nsimilarity index 100%\nrename from src/snekbak/view/status.py\nrename to src/snek/view/status.py\ndiff --git a/src/snekbak/view/web.py b/src/snek/view/web.py\nsimilarity index 100%\nrename from src/snekbak/view/web.py\nrename to src/snek/view/web.py"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added basic documentation site with API and form examples", "commit": "b562d171674c2f75592ff3a0dd25b51d2a2457db", "diff": "commit b562d171674c2f75592ff3a0dd25b51d2a2457db\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:41:34 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\nnew file mode 100644\nindex 0000000..6f355a6\nBinary files /dev/null and b/src/snek/docs/__pycache__/app.cpython-312.pyc differ\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nnew file mode 100644\nindex 0000000..50a4245\n--- /dev/null\n+++ b/src/snek/docs/app.py\n@@ -0,0 +1,43 @@\n+import pathlib\n+\n+from aiohttp import web\n+from app.app import Application as BaseApplication\n+\n+from snek.system.markdown import MarkdownExtension\n+\n+\n+class Application(BaseApplication):\n+\n+    def __init__(self, path=None, *args, **kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+\n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response\ndiff --git a/src/snek/docs/docs/api.html b/src/snek/docs/docs/api.html\nnew file mode 100644\nindex 0000000..e30a99d\n--- /dev/null\n+++ b/src/snek/docs/docs/api.html\n@@ -0,0 +1,61 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Currently only some details about the internal API are available.\n+\n+```python\n+\n+new_user_object = await app.service.user.register(\n+    username=\"retoor\", \n+    password=\"retoorded\"\n+)\n+```\n+\n+```python\n+from snek.system import security\n+\n+var1 = security.encrypt(\"data\")\n+var2 = security.encrypt(b\"data\")\n+\n+assert(var1 == var2)\n+```\n+\n+```python\n+from snek.system.view import BaseView \n+\n+class IndexView(BaseView):\n+    \n+    async def get(self):\n+        return await self.render(\"index.html\")\n+```\n+```python\n+from snek.system.view import BaseFormView\n+from snek.form.register import RegisterForm\n+\n+class RegisterFormView(BaseFormView):\n+    \n+    form = RegisterForm\n+```\n+```python\n+app.routes.add_view(\"/your-page.html\", YourViewClass)\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/base.html b/src/snek/docs/docs/base.html\nnew file mode 100644\nindex 0000000..7c53bec\n--- /dev/null\n+++ b/src/snek/docs/docs/base.html\n@@ -0,0 +1,116 @@\n+<html>\n+\n+<head>\n+    <style>{{ highlight_styles }}</style>\n+    <style>\n+        \n+        * {\n+\n+            box-sizing: border-box;\n+        }\n+\n+        .dialog {\n+\n+            border-radius: 10px;\n+            padding: 30px;\n+            width: 800px;\n+            margin: 30px;\n+            box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        }\n+\n+        @media screen and (max-width: 500px) {\n+            .center {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+            .dialog {\n+                width: 100%;\n+                left: 0px;\n+            }\n+\n+        }\n+\n+        h1 {\n+            font-size: 2em;\n+            margin-bottom: 20px;\n+        }\n+\n+        h2 {\n+            font-size: 1.4em;\n+            margin-bottom: 20px;\n+        }\n+\n+        html,body,main {\n+            font-family: Arial, sans-serif;\n+            line-height: 1.5;\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            min-height: 100vh;\n+            width: 100%;\n+        }\n+        article {\n+            max-width: 100%;\n+            width: 60%;\n+            padding: 30px;\n+            min-height: 100vh;\n+            word-break: break-all;\n+        }\n+        footer {\n+            position: fixed;\n+            width: 60%;\n+            text-align: center; \n+            bottom: 0;\n+            left: 20%;\n+        }\n+        a {\n+    display: block;\n+    margin-top: 15px;\n+    font-size: 0.9em;\n+    transition: color 0.3s;\n+  }\n+  header {\n+\n+    text-align: left;\n+    width: 60%;\n+    padding: 30px;\n+  }\n+  header a {\n+    display: inline;\n+\n+  }\n+        div {\n+            text-align: left;\n+\n+        }\n+    </style>\n+</head>\n+\n+<body>\n+    <main>\n+        <header>\n+            <a href=\"/\">Snek</a>\n+            <a href=\"/docs/docs\">Docs</a>\n+        </header>\n+      <article>\n+            {% block main %}\n+            {% endblock %}\n+        </article>\n+    </main>\n+    <footer>\n+        {% markdown %}\n+        {% endmarkdown %}\n+    </footer>\n+</body>\n+\n+</html>\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_javascript.html b/src/snek/docs/docs/form_api_javascript.html\nnew file mode 100644\nindex 0000000..c83ee7f\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_javascript.html\n@@ -0,0 +1,17 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - generic-form.js \n+\n+It's just a HTML component that can be declared using an one liner. Buttons and title are specified server side.\n+```html \n+<generic-form url=\"/url-to-form-api\"></generic-form>\n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/form_api_python.html b/src/snek/docs/docs/form_api_python.html\nnew file mode 100644\nindex 0000000..d7e67bc\n--- /dev/null\n+++ b/src/snek/docs/docs/form_api_python.html\n@@ -0,0 +1,92 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+ - `snek.system.form.Form`\n+ - `snek.system.form.HTMLElement`\n+ - `snek.system.form.FormInputElement`\n+ - `snek.system.form.FormButtonElement`\n+\n+Here is an example with custom validation. \n+This example contains a field that checks if user already exists. \n+If invalid, it adds an error message which automatically invalidates the field. \n+Handling of the error messages will automatically done client side.\n+\n+Forms are usaly located in `snek/form/[form name].py`.\n+\n+```python\n+from snek.system.form import Form, HTMLElement,FormInputElement,FormButtonElement\n+\n+class UsernameField(FormInputElement):\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\", \n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\"\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\"\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        type=\"password\",\n+        place_holder=\"Password\"\n+    )\n+    action = FormButtonElement(\n+        name=\"action\",\n+        value=\"submit\",\n+        text=\"Register\",\n+        type=\"button\"\n+    )\n+```\n+\n+\n+```python \n+data = dict(\n+    username=dict(value=\"retoor\"),\n+    password=dict(value=\"retoorded\")\n+)\n+form.set_user_data(data)\n+\n+is_valid = await form.is_valid\n+\n+key_value_values = await form.record \n+```\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/docs/docs/index.html b/src/snek/docs/docs/index.html\nnew file mode 100644\nindex 0000000..8ced8ab\n--- /dev/null\n+++ b/src/snek/docs/docs/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"docs/base.html\" %}\n+\n+{% block main %}\n+{% markdown %}\n+\n+\n+Snek is a high customizable chat application. \n+It is made because Rocket Chat didn't fit my needs anymore. It became bloathed and very heavy commercialized. You would get upsell messages on your locally hosted instance!\n+\n+This documentation is under construction. Only the form API and the small introduction is a bit documented.\n+\n+[Small introduction / cheatsheet](/docs/docs/api.html)\n+\n+With the view classes of Snek you can render HTML and Markdown \n+\n+Snek's database model is based on Python dataset library. \n+Snek uses a model/mapper architecture build on top of that library.\n+\n+Snek does have his own components for creating and rendering forms. \n+All forms are made server side and client side is generated client side using a HTML component.\n+It's client side only one line to include a form that can validate and submit.\n+Validation is server side using REST. Page won't refresh.\n+[API Python](/docs/docs/form_api_python.html) \n+[API Javascript](/docs/docs/form_api_javascript.html)\n+\n+\n+{% endmarkdown %}\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added cache directory to .gitignore", "commit": "82de0f304469e6214169a2bdcf9c65673baa9e76", "diff": "commit 82de0f304469e6214169a2bdcf9c65673baa9e76\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:42:12 2025 +0100\n\n    Added docks.\n\ndiff --git a/.gitignore b/.gitignore\nindex 5e03233..c1f3aef 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -7,7 +7,7 @@ snek.d*\n .rcontext.txt \n *.zip\n *.db*\n-*.png\n+cache\n __pycache__/"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added docks and markdown support to message templates", "commit": "80f1bbc05e612c45fb2ccbb629a6aa3b468c627e", "diff": "commit 80f1bbc05e612c45fb2ccbb629a6aa3b468c627e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:47:20 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex d2b8809..5739a59 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -5,6 +5,6 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n         {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added snek image", "commit": "9e89e27c6688b0e05e4a10a0538d599f82278e64", "diff": "commit 9e89e27c6688b0e05e4a10a0538d599f82278e64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:47:25 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/static/image/snek1.png b/src/snek/static/image/snek1.png\nnew file mode 100644\nindex 0000000..4dec2c2\nBinary files /dev/null and b/src/snek/static/image/snek1.png differ"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added notification audio and scheduling functionality", "commit": "af399e3b72c772ed97e943e7d71dc6384ab8ccc0", "diff": "commit af399e3b72c772ed97e943e7d71dc6384ab8ccc0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:08:40 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a125c4e..44853f1 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -345,13 +345,18 @@ class Socket extends EventHandler {\n \n }\n \n-class App extends EventHandler {\n-    rooms = []\n-    rest = rest\n-    ws = null\n-    rpc = null\n+class NotificationAudio {\n+    constructor(timeout){\n+        if(!timeout)\n+            timeout = 500\n+        this.schedule = new Schedule(timeout)\n+    }\n     sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n-    playSound(soundIndex) {\n+    play(soundIndex) {\n+        this.schedule.delay(() => {\n+            \n+        \n+\n         if (!soundIndex)\n             soundIndex = 0\n \n@@ -364,17 +369,30 @@ class App extends EventHandler {\n             .catch((error) => {\n                 console.error(\"Notification failed:\", error);\n             });\n+        })\n     }\n+}\n+\n+class App extends EventHandler {\n+    rooms = []\n+    rest = rest\n+    ws = null\n+    rpc = null\n+    audio = null \n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n         this.rpc = this.ws.client\n         const me = this\n+        this.audio = new NotificationAudio(500)\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n     }\n+    playSound(index){\n+        this.audio.play(index)\n+    }\n     async benchMark(times, message) {\n         if (!times)\n             times = 100\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 01fe8fb..c4b67cb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -55,7 +55,7 @@ class MessageListElement extends HTMLElement {\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n         if(message.html)\n-            text.innerHTML = this.linkifyText(message.html)\n+            text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n         time.textContent = message.created_at\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 0eb41d1..3ed8069 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -17,8 +17,8 @@ class Schedule {\n         this.interval = null \n     }\n     cancelDelay() {\n-        clearTimeout(this.interval)\n-        this.interval = null\n+        clearTimeout(this.timeOut)\n+        this.timeOut = null\n     }\n     repeat(func){\n         if(this.interval){\n@@ -35,9 +35,10 @@ class Schedule {\n         }\n         const me = this \n         this.timeOut = setTimeout(()=>{\n+            func(me.timeOutCount)\n             clearTimeout(me.timeOut)\n             me.timeOut = null\n-            func(me.timeOutCount)\n+            \n             me.cancelDelay()\n             me.timeOutCount = 0\n         }, this.msDelay)"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added Docker configuration for deployment", "commit": "3be25285f4f0afaaf991ee7cc0a8f71854e8de4c", "diff": "commit 3be25285f4f0afaaf991ee7cc0a8f71854e8de4c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:12:25 2025 +0100\n\n    Added docks.\n\ndiff --git a/compose.yml b/compose.yml\nindex 24e186c..7be013d 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -6,6 +6,8 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+    environment:\n+     - PYTHONDONTWRITEBYTECODE=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n    \n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Enable markdown rendering for messages", "commit": "75cb7605cd5e8e91cab2ffbc9000eb5987e40136", "diff": "commit 75cb7605cd5e8e91cab2ffbc9000eb5987e40136\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:15:54 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 5739a59..037cebf 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -4,7 +4,7 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}{%endautoescape%}{% endmarkdown %}\n+        {% markdown %}{% autoescape false %}{{ message }}  {%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render markdown messages with raw HTML support", "commit": "4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5", "diff": "commit 4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 17:17:27 2025 +0100\n\n    Added docks.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 037cebf..d813397 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -4,7 +4,7 @@\n     <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}  {%endautoescape%}{% endmarkdown %}\n+        {% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "chore: Remove compiled Python files", "commit": "f69586ccf7975be0bdd24659d6acec068f5183d6", "diff": "commit f69586ccf7975be0bdd24659d6acec068f5183d6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 16:19:49 2025 +0000\n\n    Deleted pyc\n\ndiff --git a/src/snek/docs/__pycache__/app.cpython-312.pyc b/src/snek/docs/__pycache__/app.cpython-312.pyc\ndeleted file mode 100644\nindex 6f355a6..0000000\nBinary files a/src/snek/docs/__pycache__/app.cpython-312.pyc and /dev/null differ"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added requests dependency and template extensions for linkification and python execution.", "commit": "bca39a612cad5f340864a4dc62d94cda962985f9", "diff": "commit bca39a612cad5f340864a4dc62d94cda962985f9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:12:22 2025 +0100\n\n    Update.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 71e90a7..22ec4eb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -23,6 +23,7 @@ dependencies = [\n     \"wkhtmltopdf\",\n     \"mistune\",\n     \"aiohttp-session\",\n-    \"cryptography\"\n+    \"cryptography\",\n+    \"requests\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex b1c20c0..c2c3651 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -16,6 +16,7 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n+from snek.system.template import LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n@@ -51,6 +52,9 @@ class Application(BaseApplication):\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n+        self.jinja2_env.add_extension(LinkifyExtension)\n+        self.jinja2_env.add_extension(PythonExtension)\n+        \n         self.setup_router()\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4b1e603..0e040e2 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,6 +209,11 @@ message-list {\n }\n \n .message {\n+  max-width: 100%;\n+  word-wrap: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n+\n   .avatar {\n       opacity: 0;\n   }\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nnew file mode 100644\nindex 0000000..8994528\n--- /dev/null\n+++ b/src/snek/system/template.py\n@@ -0,0 +1,99 @@\n+from types import SimpleNamespace\n+from bs4 import BeautifulSoup\n+import re \n+\n+\n+\n+def set_link_target_blank(text):\n+    soup = BeautifulSoup(text, 'html.parser')\n+\n+    for element in soup.find_all(\"a\"):  \n+        element.attrs['target'] = '_blank'\n+        element.attrs['rel'] = 'noopener noreferrer'\n+        element.attrs['referrerpolicy'] = 'no-referrer'\n+\n+    return str(soup)\n+    \n+\n+def linkify_https(text):\n+\n+    soup = BeautifulSoup(text, 'html.parser')\n+\n+    for element in soup.find_all(text=True):  \n+        parent = element.parent\n+        if parent.name in ['a', 'script', 'style']:  \n+            continue\n+        \n+        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n+        element.replace_with(BeautifulSoup(new_text, 'html.parser'))\n+\n+    return set_link_target_blank(str(soup))\n+\n+\n+from jinja2 import TemplateSyntaxError, nodes\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n+\n+\n+class LinkifyExtension(Extension):\n+    tags = {\"linkify\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(LinkifyExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return linkify_https(caller())\n+\n+class PythonExtension(Extension):\n+    tags = {\"py3\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        \n+        def fn(source):\n+            import subprocess \n+            import subprocess \n+            import pathlib \n+            from pathlib import Path \n+            import os \n+            import sys \n+            import requests\n+            def system(command):\n+                if isinstance(command):\n+                    command = command.split(\" \")\n+                from io import StringIO \n+                stdout = StringIO()\n+                subprocess.run(command,stderr=stdout,stdout=stdout,text=True)\n+                return stdout.getvalue()\n+            to_write = []\n+            def render(text):\n+                global to_write \n+                to_write.append(text)\n+            exec(source)\n+            return \"\".join(to_write)\n+        return str(fn(caller()))\n\\ No newline at end of file\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex d813397..36faaed 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,10 +1,14 @@\n <style>\n     {{highlight_styles}}\n </style>\n+\n-    <div data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-        {% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n+    <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n+{% linkify %}\n+\n+{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n+{% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Style message images and add time ago functionality", "commit": "5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c", "diff": "commit 5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:56:28 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0e040e2..c8af379 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,11 +209,16 @@ message-list {\n }\n \n .message {\n+  .text {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n   hyphens: auto;\n-\n+    img {\n+      max-width: 90%;\n+      border-radius: 20px;\n+    }\n+  }\n   .avatar {\n       opacity: 0;\n   }\n@@ -227,6 +232,16 @@ message-list {\n   }\n }\n .message.switch-user {\n+  .text {\n+  max-width: 100%;\n+  word-wrap: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n+    img{\n+      max-width: 90%;\n+      border-radius: 20px;\n+    }\n+  }\n   .avatar {\n       opacity: 1;\n   }\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex c4b67cb..3038ad3 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -25,6 +25,34 @@ class MessageListElement extends HTMLElement {\n             });\n         \n     }\n+    timeAgo(date1, date2) {\n+    \n+        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n+        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n+        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n+        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+        if(days){\n+            return `${days} days ago`\n+        }\n+        if(hours){\n+            return `${hours} hours ago`\n+        }\n+        if (minutes)\n+            return `${minutes} minutes ago`\n+        \n+        return `just now`\n+    }\n+    timeDescription(isoDate){\n+        if(!isoDate.endsWith(\"Z\"))\n+            isoDate += \"Z\"\n+        const date =  new Date(isoDate)\n+        const hours = String(date.getHours()).padStart(2, \"0\");\n+        const minutes = String(date.getMinutes()).padStart(2, \"0\");\n+        let timeStr = `${hours}:${minutes}`\n+        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now()) \n+        return timeStr\n+    }\n     createElement(message){\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n@@ -58,8 +86,9 @@ class MessageListElement extends HTMLElement {\n             text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n-        time.textContent = message.created_at\n+        time.dataset.created_at = message.created_at\n         messageContent.appendChild(author)\n+        time.textContent = this.timeDescription(message.created_at)\n         messageContent.appendChild(text)\n         messageContent.appendChild(time)\n         element.appendChild(avatar)\n@@ -119,6 +148,15 @@ class MessageListElement extends HTMLElement {\n         })\n         this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n         \n+        this.timeUpdateInterval = setInterval(()=>{\n+            me.messages.forEach((message)=>{\n+                const newText = me.timeDescription(message.created_at)\n+                \n+                if(newText != message.element.innerText){\n+                message.element.querySelector(\".time\").innerText = newText\n+                }\n+            })\n+        },30000)\n         \n     }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Improve time display and linkify URLs in messages", "commit": "20d8d27f03e87bf06515d0664a00e669b92df49f", "diff": "commit 20d8d27f03e87bf06515d0664a00e669b92df49f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 18:58:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 3038ad3..7211ddb 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -1,7 +1,7 @@\n \n \n class MessageListElement extends HTMLElement {\n-    \n+\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n@@ -9,51 +9,60 @@ class MessageListElement extends HTMLElement {\n     room = null\n     url = null\n     container = null\n-    messageEventSchedule = null \n-    observer = null \n+    messageEventSchedule = null\n+    observer = null\n     constructor() {\n         super()\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div')\n-        this.shadowRoot.appendChild(this.component )\n+        this.shadowRoot.appendChild(this.component)\n     }\n     linkifyText(text) {\n-            const urlRegex = /https?:\\/\\/[^\\s]+/g;\n-        \n-            return text.replace(urlRegex, (url) => {\n-                return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n-            });\n-        \n+        const urlRegex = /https?:\\/\\/[^\\s]+/g;\n+\n+        return text.replace(urlRegex, (url) => {\n+            return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n+        });\n+\n     }\n     timeAgo(date1, date2) {\n-    \n+\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n         const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n         const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n-        if(days){\n-            return `${days} days ago`\n+        if (days) {\n+            if (days > 1)\n+                return `${days} days ago`\n+            else\n+                return `${days} day ago`\n         }\n-        if(hours){\n-            return `${hours} hours ago`\n+        if (hours) {\n+            if (hours > 1)\n+                return `${hours} hours ago`\n+            else\n+                return `${hours} hour ago`\n         }\n         if (minutes)\n-            return `${minutes} minutes ago`\n-        \n+            if (minutes > 1)\n+                return `${minutes} minutes ago`\n+            else\n+                return `${minutes} minute ago`\n+\n         return `just now`\n     }\n-    timeDescription(isoDate){\n-        if(!isoDate.endsWith(\"Z\"))\n+    timeDescription(isoDate) {\n+        if (!isoDate.endsWith(\"Z\"))\n             isoDate += \"Z\"\n-        const date =  new Date(isoDate)\n+        const date = new Date(isoDate)\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n         let timeStr = `${hours}:${minutes}`\n-        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now()) \n+        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now())\n         return timeStr\n     }\n-    createElement(message){\n+    createElement(message) {\n         const element = document.createElement(\"div\")\n         element.dataset.uid = message.uid\n         element.dataset.color = message.color\n@@ -61,18 +70,18 @@ class MessageListElement extends HTMLElement {\n         element.dataset.user_nick = message.user_nick\n         element.dataset.created_at = message.created_at\n         element.dataset.user_uid = message.user_uid\n-        element.dataset.message = message.message \n-        \n+        element.dataset.message = message.message\n+\n         element.classList.add(\"message\")\n-        if(!this.messages.length){\n+        if (!this.messages.length) {\n             element.classList.add(\"switch-user\")\n-        }else if (this.messages[this.messages.length-1].user_uid != message.user_uid){\n+        } else if (this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n             element.classList.add(\"switch-user\")\n         }\n         const avatar = document.createElement(\"div\")\n         avatar.classList.add(\"avatar\")\n         avatar.style.backgroundColor = message.color\n-        avatar.style.color= \"black\"\n+        avatar.style.color = \"black\"\n         avatar.innerText = message.user_nick[0]\n         const messageContent = document.createElement(\"div\")\n         messageContent.classList.add(\"message-content\")\n@@ -82,7 +91,7 @@ class MessageListElement extends HTMLElement {\n         author.textContent = message.user_nick\n         const text = document.createElement(\"div\")\n         text.classList.add(\"text\")\n-        if(message.html)\n+        if (message.html)\n             text.innerHTML = message.html\n         const time = document.createElement(\"div\")\n         time.classList.add(\"time\")\n@@ -94,15 +103,15 @@ class MessageListElement extends HTMLElement {\n         element.appendChild(avatar)\n         element.appendChild(messageContent)\n \n-    \n \n \n-        message.element = element \n-       \n+\n+        message.element = element\n+\n         return element\n     }\n-    addMessage(message){\n-        \n+    addMessage(message) {\n+\n         const obj = new models.Message(\n             message.uid,\n             message.channel_uid,\n@@ -117,17 +126,17 @@ class MessageListElement extends HTMLElement {\n         const element = this.createElement(obj)\n         this.messages.push(obj)\n         this.container.appendChild(element)\n-        const me = this \n-        \n+        const me = this\n+\n         this.messageEventSchedule.delay(() => {\n-            me.dispatchEvent(new CustomEvent(\"message\", {detail:obj,bubbles:true}))\n-        \n+            me.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }))\n+\n         })\n-       \n+\n \n         return obj\n     }\n-    scrollBottom(){\n+    scrollBottom() {\n         this.container.scrollTop = this.container.scrollHeight;\n     }\n     connectedCallback() {\n@@ -146,18 +155,18 @@ class MessageListElement extends HTMLElement {\n         app.addEventListener(this.channel_uid, (data) => {\n             me.addMessage(data)\n         })\n-        this.dispatchEvent(new CustomEvent(\"rendered\", {detail:this,bubbles:true}))\n-        \n-        this.timeUpdateInterval = setInterval(()=>{\n-            me.messages.forEach((message)=>{\n+        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }))\n+\n+        this.timeUpdateInterval = setInterval(() => {\n+            me.messages.forEach((message) => {\n                 const newText = me.timeDescription(message.created_at)\n-                \n-                if(newText != message.element.innerText){\n-                message.element.querySelector(\".time\").innerText = newText\n+\n+                if (newText != message.element.innerText) {\n+                    message.element.querySelector(\".time\").innerText = newText\n                 }\n             })\n-        },30000)\n-        \n+        }, 30000)\n+\n     }\n }"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Prevent linkify_https from modifying non-https text", "commit": "3d6e1d2e943baabaf0b0875284bf18132bc3967a", "diff": "commit 3d6e1d2e943baabaf0b0875284bf18132bc3967a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:13:41 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8994528..69222b6 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -16,6 +16,8 @@ def set_link_target_blank(text):\n     \n \n def linkify_https(text):\n+        return text \n \n     soup = BeautifulSoup(text, 'html.parser')"}
{"repo": ".", "date": "2025-01-29", "line": "refactor: Removed unused padding rule in base.css", "commit": "5c4c5793899776e5d369f3949b4a8142a68ba7ee", "diff": "commit 5c4c5793899776e5d369f3949b4a8142a68ba7ee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:17:01 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex c8af379..42d4b33 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,6 +1,6 @@\n * {\n   margin: 0;\n-  padding: 0;\n   box-sizing: border-box;\n }"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Display install button inline-block", "commit": "a8e3ad1af9f683ad25730ff48180c5306f72e1f6", "diff": "commit a8e3ad1af9f683ad25730ff48180c5306f72e1f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 19:47:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1d660f6..b2e9677 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -52,7 +52,7 @@ let installPrompt = null\n             const result = await installPrompt.prompt()\n             console.info(result.outcome)\n             })\n-            button.style.display = 'block'\n+            button.style.display = 'inline-block'\n             \n           });\n         ;"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added username to chat messages", "commit": "99cea506de5ea0c5b373869b7c28d965b8af55e6", "diff": "commit 99cea506de5ea0c5b373869b7c28d965b8af55e6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 22:15:25 2025 +0100\n\n    Username support.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex b31f747..02a4bd5 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -24,6 +24,7 @@ class ChatService(BaseService):\n             channel_uid=channel_uid,\n             created_at=channel_message[\"created_at\"], \n             updated_at=None,\n+            username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 6bbfa20..b48f4d0 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -74,7 +74,8 @@ class RPCView(BaseView):\n                     user_nick=user['nick'],\n                     message=message[\"message\"],\n                     created_at=message[\"created_at\"],\n-                    html=message['html'] \n+                    html=message['html'],\n+                    username=user['username'] \n                 ))\n             return messages"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected calculation in timeAgo function", "commit": "03e90039695abc0fdc9276980bd8728bd8951f05", "diff": "commit 03e90039695abc0fdc9276980bd8728bd8951f05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Jan 29 23:20:35 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 7211ddb..67a12c2 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -26,7 +26,7 @@ class MessageListElement extends HTMLElement {\n \n     }\n     timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1); \n \n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added SSH service and improved gallery styling", "commit": "b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5", "diff": "commit b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:06:23 2025 +0100\n\n    Added traceback\n\ndiff --git a/compose.yml b/compose.yml\nindex 7be013d..ce2b02c 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -10,4 +10,17 @@ services:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n-   \n\\ No newline at end of file\n+  snecssh:\n+    build:\n+      context: .\n+      dockerfile: DockerfileDrive\n+    restart: always\n+    ports:\n+      - \"2225:2225\"\n+    volumes:\n+      - ./:/code\n+    environment:\n+     - PYTHONDONTWRITEBYTECODE=\"1\"\n+    entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]\n+   \ndiff --git a/pyproject.toml b/pyproject.toml\nindex 22ec4eb..a2a0ccb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -24,6 +24,7 @@ dependencies = [\n     \"mistune\",\n     \"aiohttp-session\",\n     \"cryptography\",\n-    \"requests\"\n+    \"requests\",\n+    \"asyncssh\"\n ]\n \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 42d4b33..46b9fef 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -4,6 +4,29 @@\n   box-sizing: border-box;\n }\n \n+.gallery {\n+  padding: 50px;\n+  height: auto;\n+  overflow: auto;\n+  flex: 1;\n+  &.tile {\n+  width: 100px;\n+  height: 100px;\n+  object-fit: cover;\n+  margin-right: 10px;\n+  border-radius: 5px;\n+  margin: 20px;\n+  }\n+}\n+.tile {\n+  \n+    width: 100px;\n+    height: 100px;\n+    object-fit: cover;\n+    margin-right: 10px;\n+    border-radius: 5px;\n+    margin: 20px;\n+}\n \n body {\n   font-family: Arial, sans-serif;\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex bdad37e..58845b4 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -23,6 +23,7 @@ class ChatWindowElement extends HTMLElement {\n         const chatHeader = document.createElement(\"div\")\n         chatHeader.classList.add(\"chat-header\")\n         \n+\n         \n         \n         const chatTitle = document.createElement('h2')\n@@ -37,7 +38,13 @@ class ChatWindowElement extends HTMLElement {\n         channelElement.setAttribute(\"channel\", channel.uid)\n         this.container.appendChild(channelElement)\n-\n         const chatInput = document.createElement('chat-input')\n         \n         chatInput.addEventListener(\"submit\",(e)=>{\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex b2e9677..3882548 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <script src=\"/media-upload.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b48f4d0..3619bd6 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,5 +1,6 @@\n from aiohttp import web  \n from snek.system.view import BaseView\n+import traceback\n \n \n class RPCView(BaseView):\n@@ -117,7 +118,11 @@ class RPCView(BaseView):\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n-                result = await method(*args)\n+                try:\n+                    result = await method(*args)\n+                except Exception as ex:\n+                    result = dict({\"callId\":call_id,\"success\": False, \"data\":{\"exception\":str(ex),\"traceback\":traceback.format_exc()}}) \n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n                 await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n@@ -145,4 +150,4 @@ class RPCView(BaseView):\n \n         await self.services.socket.delete(ws)\n         print(\"WebSocket connection closed\")\n-        return ws\n\\ No newline at end of file\n+        return ws"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Initialized drive functionality with Dockerfile and basic files", "commit": "15de277a5be330fe6962e5271c537e3b5ef40de4", "diff": "commit 15de277a5be330fe6962e5271c537e3b5ef40de4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:06:42 2025 +0100\n\n    drive.\n\ndiff --git a/DockerfileDrive b/DockerfileDrive\nnew file mode 100644\nindex 0000000..28f183a\n--- /dev/null\n+++ b/DockerfileDrive\n@@ -0,0 +1,16 @@\n+FROM python:3.12.8-alpine3.21\n+WORKDIR /code\n+RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n+\n+\n+COPY setup.cfg setup.cfg \n+COPY pyproject.toml pyproject.toml \n+COPY src src\n+COpy ssh_host_key ssh_host_key\n+RUN pip install --upgrade pip\n+RUN pip install -e .\n+EXPOSE 2225\n+\ndiff --git a/src/snek/static/media-upload.js b/src/snek/static/media-upload.js\nnew file mode 100644\nindex 0000000..e190aed\n--- /dev/null\n+++ b/src/snek/static/media-upload.js\n@@ -0,0 +1,167 @@\n+class TileGridElement extends HTMLElement {\n+                \n+    constructor() {\n+        super();\n+        this.attachShadow({mode: 'open'});\n+        this.gridId = this.getAttribute('grid');\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component)\n+    }\n+  \n+\n+    connectedCallback() {\n+        console.log('connected');\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerText = `\n+            .grid {\n+            padding: 10px;\n+                display: flex;\n+                flex-wrap: wrap;\n+                gap: 10px;\n+                justify-content: center;\n+            }\n+            .grid .tile {\n+                margin: 10px;\n+                width: 100px;\n+                height: 100px;\n+                object-fit: cover;\n+                border-radius: 10px;\n+                transition: transform 0.3s ease-in-out;\n+            }\n+            .grid .tile:hover {\n+                transform: scale(1.1);\n+            }\n+\n+        `;\n+        this.component.appendChild(this.styleElement);\n+        this.container = document.createElement('div');\n+        this.container.classList.add('gallery');\n+        this.component.appendChild(this.container);\n+    }\n+    addImage(src) {\n+        const item = document.createElement('img');\n+        item.src = src;\n+        item.classList.add('tile');\n+        item.style.width = '100px';\n+        item.style.height = '100px';\n+        this.container.appendChild(item);\n+    }\n+    addImages(srcs) {\n+        srcs.forEach(src => this.addImage(src));\n+    }\n+    addElement(element) {\n+        element.cclassList.add('tile');\n+        this.container.appendChild(element);\n+    }\n+\n+}\n+\n+class UploadButton extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({mode: 'open'});\n+        this.component = document.createElement('div');\n+    \n+        this.shadowRoot.appendChild(this.component)\n+        window.u = this\n+    }\n+    get gridSelector(){\n+        return this.getAttribute('grid');\n+    }\n+    grid = null\n+\n+    addImages(urls) {\n+        this.grid.addImages(urls);\n+    }\n+    connectedCallback()\n+    {\n+        console.log('connected');\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerHTML = `\n+        .upload-button {\n+            display: flex;\n+            flex-direction: column;\n+            align-items: center;\n+            justify-content: center;\n+            gap: 10px;\n+        }\n+        .upload-button input[type=\"file\"] {\n+            display: none;\n+        }\n+        .upload-button label {\n+            display: block;\n+            padding: 10px 20px;\n+            border-radius: 5px;\n+            cursor: pointer;\n+        }\n+        .upload-button label:hover {\n+        }\n+        `;\n+        this.shadowRoot.appendChild(this.styleElement);\n+        this.container = document.createElement('div');\n+        this.container.classList.add('upload-button');\n+        this.shadowRoot.appendChild(this.container);\n+        const input = document.createElement('input');\n+        input.type = 'file';\n+        input.multiple = true;\n+        input.addEventListener('change', (e) => {\n+            const files = e.target.files;\n+            const urls = [];\n+            for (let i = 0; i < files.length; i++) {\n+                const file = files[i];\n+                const reader = new FileReader();\n+                reader.onload = (e) => {\n+                    urls.push(e.target.result);\n+                    if (urls.length === files.length) {\n+                        this.addImages(urls);\n+                    }\n+                };\n+                reader.readAsDataURL(file);\n+            }\n+         });\n+        const label = document.createElement('label');\n+        label.textContent = 'Upload Images';\n+        label.appendChild(input);\n+        this.container.appendChild(label);\n+    }\n+}\n+\n+customElements.define('upload-button', UploadButton);  \n+\n+customElements.define('tile-grid', TileGridElement);\n+\n+class MeniaUploadElement extends HTMLElement {\n+\n+    constructor(){\n+        super()\n+        this.attachShadow({mode:'open'})\n+        this.component = document.createElement(\"div\")\n+        alert('aaaa')\n+        this.shadowRoot.appendChild(this.component)\n+    }\n+    connectedCallback() {\n+        \n+        this.container = document.createElement(\"div\")\n+        this.component.style.height = '100%'\n+        this.component.style.backgroundColor ='blue';\n+        this.shadowRoot.appendChild(this.container)\n+\n+        this.tileElement = document.createElement(\"tile-grid\")\n+        this.tileElement.style.backgroundColor = 'red'\n+        this.tileElement.style.height = '100%'\n+        this.component.appendChild(this.tileElement)\n+        \n+        this.uploadButton = document.createElement('upload-button')\n+        this.component.appendChild(this.uploadButton)\n+        \n+    }\n+\n+}\n+\n+customElements.define('menia-upload', MeniaUploadElement)\n\\ No newline at end of file\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nnew file mode 100644\nindex 0000000..27e1b4b\n--- /dev/null\n+++ b/src/snekssh/app.py\n@@ -0,0 +1,71 @@\n+import asyncio\n+import asyncssh\n+import os\n+import logging\n+asyncssh.set_debug_level(2)\n+logging.basicConfig(level=logging.DEBUG)\n+USERNAME = \"test\"\n+PASSWORD = \"woeii\"\n+HOST = \"localhost\"\n+PORT = 2225\n+\n+class MySFTPServer(asyncssh.SFTPServer):\n+    def __init__(self, chan):\n+        super().__init__(chan)\n+        self.root = os.path.abspath(SFTP_ROOT)\n+\n+    async def stat(self, path):\n+        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().stat(full_path)\n+\n+    async def open(self, path, flags, attrs):\n+        \"\"\"Handles file open requests\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().open(full_path, flags, attrs)\n+\n+    async def listdir(self, path):\n+        \"\"\"Handles directory listing\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().listdir(full_path)\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    \"\"\"Custom SSH server to handle authentication\"\"\"\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def begin_auth(self, username):\n+\n+    def password_auth_supported(self):\n+\n+    def validate_password(self, username, password):\n+        print(username,password)\n+        \n+        return True\n+        return username == USERNAME and password == PASSWORD\n+\n+async def start_sftp_server():\n+\n+    await asyncssh.create_server(\n+        lambda: MySSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=MySFTPServer\n+    )\n+    print(f\"SFTP server running on {HOST}:{PORT}\")\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_sftp_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SFTP server: {e}\")\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nnew file mode 100644\nindex 0000000..1bfec21\n--- /dev/null\n+++ b/src/snekssh/app2.py\n@@ -0,0 +1,68 @@\n+import asyncio\n+import asyncssh\n+import os\n+\n+HOST = \"0.0.0.0\"\n+PORT = 2225\n+USERNAME = \"user\"\n+PASSWORD = \"password\"\n+\n+class CustomSSHServer(asyncssh.SSHServer):\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        return username == USERNAME and password == PASSWORD\n+\n+async def custom_bash_process(process):\n+    \"\"\"Spawns a custom bash shell process\"\"\"\n+    env = os.environ.copy()\n+    env[\"TERM\"] = \"xterm-256color\"\n+\n+    bash_proc = await asyncio.create_subprocess_exec(\n+        SHELL, \"-i\", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env\n+    )\n+\n+    async def read_output():\n+        while True:\n+            data = await bash_proc.stdout.read(1)\n+            if not data:\n+                break\n+            process.stdout.write(data)\n+\n+    async def read_input():\n+        while True:\n+            data = await process.stdin.read(1)\n+            if not data:\n+                break\n+            bash_proc.stdin.write(data)\n+\n+    await asyncio.gather(read_output(), read_input())\n+\n+async def start_ssh_server():\n+    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n+    await asyncssh.create_server(\n+        lambda: CustomSSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=custom_bash_process\n+    )\n+    print(f\"SSH server running on {HOST}:{PORT}\")\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_ssh_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SSH server: {e}\")\n+\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nnew file mode 100644\nindex 0000000..d50cc54\n--- /dev/null\n+++ b/src/snekssh/app3.py\n@@ -0,0 +1,71 @@\n+\n+\n+import asyncio, asyncssh, sys\n+\n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+\n+\n+\n+\n+\n+    width, height, pixwidth, pixheight = process.term_size\n+\n+    process.stdout.write(f'Terminal type: {process.term_type}, '\n+                         f'size: {width}x{height}')\n+    if pixwidth and pixheight:\n+        process.stdout.write(f' ({pixwidth}x{pixheight} pixels)')\n+    process.stdout.write('\\nTry resizing your window!\\n')\n+\n+    while not process.stdin.at_eof():\n+        try:\n+            await process.stdin.read()\n+        except asyncssh.TerminalSizeChanged as exc:\n+            process.stdout.write(f'New window size: {exc.width}x{exc.height}')\n+            if exc.pixwidth and exc.pixheight:\n+                process.stdout.write(f' ({exc.pixwidth}'\n+                                     f'x{exc.pixheight} pixels)')\n+            process.stdout.write('\\n')\n+\n+\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen('', 2230, server_host_keys=['ssh_host_key'],\n+                          process_factory=handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nnew file mode 100644\nindex 0000000..e54480f\n--- /dev/null\n+++ b/src/snekssh/app4.py\n@@ -0,0 +1,77 @@\n+\n+\n+import asyncio, asyncssh, bcrypt, sys\n+from typing import Optional\n+\n+             'user': bcrypt.hashpw(b'user', bcrypt.gensalt()),\n+            }\n+\n+def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+    username = process.get_extra_info('username')\n+    process.stdout.write(f'Welcome to my SSH server, {username}!\\n')\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n+        peername = conn.get_extra_info('peername')[0]\n+        print(f'SSH connection received from {peername}.')\n+\n+    def connection_lost(self, exc: Optional[Exception]) -> None:\n+        if exc:\n+            print('SSH connection error: ' + str(exc), file=sys.stderr)\n+        else:\n+            print('SSH connection closed.')\n+\n+    def begin_auth(self, username: str) -> bool:\n+        return passwords.get(username) != b''\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        if username not in passwords:\n+            return False\n+        pw = passwords[username]\n+        if not password and not pw:\n+            return True\n+        return bcrypt.checkpw(password.encode('utf-8'), pw)\n+\n+async def start_server() -> None:\n+    await asyncssh.create_server(MySSHServer, '', 2231,\n+                                 server_host_keys=['ssh_host_key'],\n+                                 process_factory=handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nnew file mode 100644\nindex 0000000..bbaf3d3\n--- /dev/null\n+++ b/src/snekssh/app5.py\n@@ -0,0 +1,106 @@\n+\n+\n+import asyncio, asyncssh, sys\n+from typing import List, cast\n+\n+class ChatClient:\n+    _clients: List['ChatClient'] = []\n+\n+    def __init__(self, process: asyncssh.SSHServerProcess):\n+        self._process = process\n+\n+    @classmethod\n+    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n+        await cls(process).run()\n+\n+    \n+\n+    async def readline(self) -> str:\n+        return cast(str, self._process.stdin.readline())\n+\n+    def write(self, msg: str) -> None:\n+        self._process.stdout.write(msg)\n+\n+    def broadcast(self, msg: str) -> None:\n+        for client in self._clients:\n+            if client != self:\n+                client.write(msg)\n+\n+\n+    def begin_auth(self, username: str) -> bool:\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        return True\n+\n+\n+    async def run(self) -> None:\n+        self.write('Welcome to chat!\\n\\n')\n+\n+        self.write('Enter your name: ')\n+        name = (await self.readline()).rstrip('\\n')\n+\n+        self.write(f'\\n{len(self._clients)} other users are connected.\\n\\n')\n+\n+        self._clients.append(self)\n+        self.broadcast(f'*** {name} has entered chat ***\\n')\n+\n+        try:\n+            async for line in self._process.stdin:\n+                self.broadcast(f'{name}: {line}')\n+        except asyncssh.BreakReceived:\n+            pass\n+\n+        self.broadcast(f'*** {name} has left chat ***\\n')\n+        self._clients.remove(self)\n+\n+async def start_server() -> None:\n+    await asyncssh.listen('', 2235, server_host_keys=['ssh_host_key'],\n+                          process_factory=ChatClient.handle_client)\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit('Error starting server: ' + str(exc))\n+\n+loop.run_forever()\ndiff --git a/ssh_host_key b/ssh_host_key\nnew file mode 100644\nindex 0000000..b65e9f2\n--- /dev/null\n+++ b/ssh_host_key\n@@ -0,0 +1,27 @@\n+-----BEGIN OPENSSH PRIVATE KEY-----\n+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\n+NhAAAAAwEAAQAAAQEAqKVFRuoa+WMGl0Z6UAYu943G9pInhd8nnsqPinu6t+D6X9U4A1bF\n+u46JBfIdwJMHoGUzVnLdDL9OO38yvZXnDXmRSEQmQhYL981BwFSFPBRNPYrY5bF6qgM6IW\n+gH8RFL5tLA5RZoUpoyKzwIllXIyKHIcxwIf6be2G8KrheuXu9/Le0Tq3f6n3LhPEwhPD0m\n+7Nah3LZS4cU+G3TcET8jh6Nw/uE7wV11ojzjrn0Fa+2HCyUSGvp4Xt9gNZmlOrY4l+22DK\n+ZUf/Cv+0ebMpngaMjYfYKFWCprwElf/YxKn6NKCcJ2KaU9kUKACpitN8DzrwA6o6VROCQ1\n+JmPq8b0PmwAAA8gZwhWRGcIVkQAAAAdzc2gtcnNhAAABAQCopUVG6hr5YwaXRnpQBi73jc\n+b2kieF3yeeyo+Ke7q34Ppf1TgDVsW7jokF8h3AkwegZTNWct0Mv047fzK9lecNeZFIRCZC\n+Fgv3zUHAVIU8FE09itjlsXqqAzohaAfxEUvm0sDlFmhSmjIrPAiWVcjIochzHAh/pt7Ybw\n+quF65e738t7ROrd/qfcuE8TCE8PSbs1qHctlLhxT4bdNwRPyOHo3D+4TvBXXWiPOOufQVr\n+7YcLJRIa+nhe32A1maU6tjiX7bYMplR/8K/7R5symeBoyNh9goVYKmvASV/9jEqfo0oJwn\n+YppT2RQoAKmK03wPOvADqjpVE4JDUmY+rxvQ+bAAAAAwEAAQAAAQAGgb66U+s2HUSY1TOA\n+H1NalWuZNRE1tuMP2yyBbfEWifI/FlPyu26McQOgz7NA+RGw3GOIF1oOCHRbrINOeBhesO\n+0SrVNCKeWZg9Lgn9VpxWkn61G5DKL5KLMdrNmsytUNExHPa121EJWU83XSKLDDHox3j7WI\n+PCEAl5aa5vdnjdf5LXmZKzVECx7pbEbQvvcC/8uTjK4nfBphVDGY43C8mo7hNeSIl6Rpcm\n+Vr2W3p8PXahYjHjMoKasosiuyf79lxrTUXbGTVjI3eiBAAAAgQCXYkyC83how+QsnygxZi\n+1xuE4hTD0BhCWTJFtuuKAIb3uib3ciMkxxy5qbfW2AfHb3vngqim+rPAwoxW55YuLTs5zu\n+1yO5k1ieWgn/ubDWLr3j8+1yCrVSha33Hyd6/NaffIuLM4Oy72zKrAq73b5tWrLNvllxic\n+i/kZ5YkYbQrQAAAIEA3RZMiNtHMB0xev2gf6bPYxYvPoZvzd66p+P1+4EVTdCrYAdRZLWZ\n+UETfWZt6YZYwbRpwrZatOampyEUy6ApQH4ga75LBRQo0P3SXP441XZucWm6X4PRYyKY7VT\n+fhfAdgbrUBOPcOrEAdBT3W56PjPnY6apUXy07xSoZ7WuhLKMEAAACBAMNG9n+7o0rBtxZe\n+6DCtM8xYsCh122+NWiLRck95rxYzhv2xm0k3xLE2CdIZuM4+KJnG/5SwOxDY2cHdkXpF+6\n+InwyWxvnV6TiCsLxYrsmMToHZP7U1mwdxWaV0xySxhaIamwnYJOqYm4uNaL8VTaNzkGzjs\n+quwPw1GCjmh9j1NbAAAADnJldG9vckByZXRvb3IyAQIDBA==\n+-----END OPENSSH PRIVATE KEY-----\ndiff --git a/ssh_host_key.pub b/ssh_host_key.pub\nnew file mode 100644\nindex 0000000..ca1693e\n--- /dev/null\n+++ b/ssh_host_key.pub\n@@ -0,0 +1 @@\n+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCopUVG6hr5YwaXRnpQBi73jcb2kieF3yeeyo+Ke7q34Ppf1TgDVsW7jokF8h3AkwegZTNWct0Mv047fzK9lecNeZFIRCZCFgv3zUHAVIU8FE09itjlsXqqAzohaAfxEUvm0sDlFmhSmjIrPAiWVcjIochzHAh/pt7YbwquF65e738t7ROrd/qfcuE8TCE8PSbs1qHctlLhxT4bdNwRPyOHo3D+4TvBXXWiPOOufQVr7YcLJRIa+nhe32A1maU6tjiX7bYMplR/8K/7R5symeBoyNh9goVYKmvASV/9jEqfo0oJwnYppT2RQoAKmK03wPOvADqjpVE4JDUmY+rxvQ+b retoor@retoor2"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Enable unbuffered python output", "commit": "8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6", "diff": "commit 8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:17:10 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/compose.yml b/compose.yml\nindex ce2b02c..ced4111 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -8,6 +8,7 @@ services:\n       - ./:/code\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n+     - PYTHONUNBUFFERED=\"1\"\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n@@ -21,6 +22,7 @@ services:\n       - ./:/code\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n+     - PYTHONUNBUFFERED=\"1\"\n     entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for response messages", "commit": "4de93489ef01bf070f461915989be611156121dd", "diff": "commit 4de93489ef01bf070f461915989be611156121dd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:21:53 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3619bd6..bdcd025 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -78,6 +78,7 @@ class RPCView(BaseView):\n                     html=message['html'],\n                     username=user['username'] \n                 ))\n+                print(\"Response messages:\",messages,flush=True)\n             return messages\n         \n         async def get_channels(self):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Improve socket error logging and flushing", "commit": "1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d", "diff": "commit 1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:31:22 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 0c11208..92fd33d 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -27,8 +27,8 @@ class SocketService(BaseService):\n             try:\n                 await ws.send_json(message)\n             except Exception as ex:\n-                print(ex)\n-                print(\"Deleting socket.\")\n+                print(ex,flush=True)\n+                print(\"Deleting socket.\",flush=True)\n                 self.subscriptions[channel_uid].remove(ws)\n                 continue \n             count += 1\n@@ -37,4 +37,4 @@ class SocketService(BaseService):\n         try:\n             self.sockets.remove(ws) \n         except IndexError:\n-            pass \n\\ No newline at end of file\n+            pass"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for incoming websocket messages", "commit": "312b9eeecaee5d16247a7f0c694e3168893c389d", "diff": "commit 312b9eeecaee5d16247a7f0c694e3168893c389d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:34:06 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex bdcd025..431bf72 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -144,6 +144,7 @@ class RPCView(BaseView):\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n+            print(msg)\n             if msg.type == web.WSMsgType.TEXT:\n                 await rpc(msg.json())\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Simplify error handling in RPCView", "commit": "c6f43931664c01c597c642e35c64ec49f3008101", "diff": "commit c6f43931664c01c597c642e35c64ec49f3008101\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:38:21 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 431bf72..cd12786 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -122,7 +122,7 @@ class RPCView(BaseView):\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"callId\":call_id,\"success\": False, \"data\":{\"exception\":str(ex),\"traceback\":traceback.format_exc()}}) \n+                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()}) \n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n@@ -137,10 +137,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\") is True:\n-            await self.services.socket.add(ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n             print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Remove debug print statement", "commit": "5fd03efc301d722a5ee09c8b0cef6d04c1130fd3", "diff": "commit 5fd03efc301d722a5ee09c8b0cef6d04c1130fd3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:38:58 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex cd12786..db11e80 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -141,7 +141,7 @@ class RPCView(BaseView):\n-            print(\"Subscribed for: \", subscription[\"label\"],flush=True)\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n             print(msg)"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Handle websocket close events and improve socket deletion", "commit": "780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64", "diff": "commit 780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:43:38 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 92fd33d..b058044 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -36,5 +36,5 @@ class SocketService(BaseService):\n     async def delete(self, ws):\n         try:\n             self.sockets.remove(ws) \n-        except IndexError:\n+        except :\n             pass \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex db11e80..5cfc284 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -149,7 +149,8 @@ class RPCView(BaseView):\n                 await rpc(msg.json())\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n-\n-        await self.services.socket.delete(ws)\n+                await self.services.socket.delete(ws)\n+            elif msg.type == web.WSMsgType.CLOSE:\n+                await self.services.socket.delete(ws)\n         print(\"WebSocket connection closed\")\n         return ws"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Added logging for RPC exceptions", "commit": "cc3b896d2cd80affc251434800844829cc3fb6e1", "diff": "commit cc3b896d2cd80affc251434800844829cc3fb6e1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:45:42 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5cfc284..5a600e3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -122,7 +122,8 @@ class RPCView(BaseView):\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()}) \n+                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    print(result,flush=True)\n                 await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Use internal send method for RPC responses", "commit": "0a70e80668a598c909674c78b654b5ad8e6afce5", "diff": "commit 0a70e80668a598c909674c78b654b5ad8e6afce5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:49:47 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5a600e3..4372e34 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -115,7 +115,7 @@ class RPCView(BaseView):\n                     raise Exception(\"Not allowed\")\n                 args = data.get(\"args\")\n                 if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                    return await self.ws.send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n+                    return await self._send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n@@ -125,9 +125,12 @@ class RPCView(BaseView):\n                     result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n                     print(result,flush=True)\n-                await self.ws.send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+                await self._send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n             except Exception as ex:\n-                await self.ws.send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+                await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+\n+    async def _send_json(self,obj):\n+        await self.ws.send_text(json.dumps(obj,default=str))\n \n     async def call_ping(self,callId,*args):\n         return {\"pong\": args}\n@@ -147,7 +150,13 @@ class RPCView(BaseView):\n         async for msg in ws:\n             print(msg)\n             if msg.type == web.WSMsgType.TEXT:\n-                await rpc(msg.json())\n+                try:\n+                    await rpc(msg.json())\n+                except Exception as ex:\n+                    print(ex,flush=True)\n+                    print(traceback.format_exc(),flush=True)\n+                    await self.services.socket.delete(ws)\n+                    break\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n                 await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Made _send_json and call_ping async functions", "commit": "bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae", "diff": "commit bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:51:47 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 4372e34..3e47bcd 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -129,11 +129,11 @@ class RPCView(BaseView):\n             except Exception as ex:\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n-    async def _send_json(self,obj):\n-        await self.ws.send_text(json.dumps(obj,default=str))\n+        async def _send_json(self,obj):\n+            await self.ws.send_text(json.dumps(obj,default=str))\n \n-    async def call_ping(self,callId,*args):\n-        return {\"pong\": args}\n+        async def call_ping(self,callId,*args):\n+            return {\"pong\": args}\n \n \n     async def get(self):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Use `send_str` instead of `send_text` for JSON serialization", "commit": "010f3b03a0983843c74219484e78c50d595da6e7", "diff": "commit 010f3b03a0983843c74219484e78c50d595da6e7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:52:22 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3e47bcd..f7b61f3 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -130,7 +130,7 @@ class RPCView(BaseView):\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n \n         async def _send_json(self,obj):\n-            await self.ws.send_text(json.dumps(obj,default=str))\n+            await self.ws.send_str(json.dumps(obj,default=str))\n \n         async def call_ping(self,callId,*args):\n             return {\"pong\": args}"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Import json for RPC handling", "commit": "8f502af84eea60b5349fd1980d352f0f8e001502", "diff": "commit 8f502af84eea60b5349fd1980d352f0f8e001502\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:52:56 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex f7b61f3..fc9f2e8 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,7 +1,7 @@\n from aiohttp import web  \n from snek.system.view import BaseView\n import traceback\n-\n+import json\n \n class RPCView(BaseView):"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Ensure messages are flushed to console", "commit": "10c7232a8f6378eed8f5b4adecca8d582d57a069", "diff": "commit 10c7232a8f6378eed8f5b4adecca8d582d57a069\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 12:57:12 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex fc9f2e8..b6a3f8c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -148,7 +148,7 @@ class RPCView(BaseView):\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:\n-            print(msg)\n+            print(msg,flush=True)\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     await rpc(msg.json())"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent premature socket deletion on error/close", "commit": "88749ce05c7c4e9b5e238d16cb9fa4053f092fc1", "diff": "commit 88749ce05c7c4e9b5e238d16cb9fa4053f092fc1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:39:48 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b6a3f8c..be1a743 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -159,8 +159,10 @@ class RPCView(BaseView):\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n                 print(f\"WebSocket exception {ws.exception()}\")\n-                await self.services.socket.delete(ws)\n+                pass \n             elif msg.type == web.WSMsgType.CLOSE:\n-                await self.services.socket.delete(ws)\n+                pass \n         print(\"WebSocket connection closed\")\n         return ws"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Unbuffered websocket subscriptions", "commit": "2ae2e8450cad47031067f3baae3b09ff521c5c87", "diff": "commit 2ae2e8450cad47031067f3baae3b09ff521c5c87\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:42:12 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex be1a743..eefc4f9 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -141,10 +141,10 @@ class RPCView(BaseView):\n         \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n+        if self.request.session.get(\"logged_in\") is True:\n+            await self.services.socket.add(ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n+                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n         rpc = RPCView.RPCApi(self,ws)\n         async for msg in ws:"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Removed unnecessary style block in message template", "commit": "495543144d464121af0afab6545a5267ad561a57", "diff": "commit 495543144d464121af0afab6545a5267ad561a57\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:43:01 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 36faaed..1bc473c 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,6 +1,6 @@\n-<style>\n     {{highlight_styles}}\n-</style>\n \n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n@@ -11,4 +11,4 @@\n {% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added highlight styles to web.html", "commit": "cfd3e7881eca77d10d32de2440a9d2b03aeaea96", "diff": "commit cfd3e7881eca77d10d32de2440a9d2b03aeaea96\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:45:41 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3882548..532f75f 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,6 +4,7 @@\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n+  <style>{{highlight_styles}}</style>\n   <script src=\"/media-upload.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n@@ -59,4 +60,4 @@ let installPrompt = null\n         ;\n     </script>\n </body>\n-</html>\n\\ No newline at end of file\n+</html>"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Streamlined message template rendering", "commit": "efe12644eda127170a3d60e086fa31ed940fca6e", "diff": "commit efe12644eda127170a3d60e086fa31ed940fca6e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 13:47:56 2025 +0100\n\n    Unbuffered.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 1bc473c..20820d3 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,14 +1,5 @@\n-    {{highlight_styles}}\n-\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-{% linkify %}\n-\n-{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}\n-{% endlinkify %}\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent default event on change and keyup events", "commit": "7526bcc816ffb759e3708f30167b4d3367955b64", "diff": "commit 7526bcc816ffb759e3708f30167b4d3367955b64\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:17:40 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 1ea2c2e..661bf8b 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -27,10 +27,12 @@ class ChatInputElement extends HTMLElement {\n             button.disabled = !message;\n         })\n         this.textBox.addEventListener('change', (e) => {\n+            e.preventDefault()\n             this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n         this.textBox.addEventListener('keyup', (e) => {\n+            e.preventDefault()\n             if (e.key == 'Enter' && !e.shiftKey) {\n                 this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n@@ -47,4 +49,4 @@ class ChatInputElement extends HTMLElement {\n         this.component.appendChild(this.container)\n     }\n }\n-customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\n+customElements.define('chat-input', ChatInputElement);"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent form submission on Shift+Enter in chat input", "commit": "1999a6c8d8dd4fdbd48d5553a1704dfa065275ee", "diff": "commit 1999a6c8d8dd4fdbd48d5553a1704dfa065275ee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:24:50 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 661bf8b..61648c1 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -31,11 +31,14 @@ class ChatInputElement extends HTMLElement {\n             this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n             console.error(e.target.value)\n         })\n-        this.textBox.addEventListener('keyup', (e) => {\n-            e.preventDefault()\n-            if (e.key == 'Enter' && !e.shiftKey) {\n-                this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n+        this.textBox.addEventListener('keydown', (e) => {\n+\n+            if (e.key == 'Enter') {\n+                if(!e.shiftKey){\n+                    e.preventDefault()\n+                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n                 e.target.value = ''\n+                }\n             }\n         })"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent empty messages from being submitted", "commit": "ae5fffe5e0faf948a22feea0e651e08a0ed559fb", "diff": "commit ae5fffe5e0faf948a22feea0e651e08a0ed559fb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:33:18 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 61648c1..4fd2845 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -36,7 +36,10 @@ class ChatInputElement extends HTMLElement {\n             if (e.key == 'Enter') {\n                 if(!e.shiftKey){\n                     e.preventDefault()\n-                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: e.target.value, bubbles: true }))\n+                    const message = e.target.value.trim();\n+                    if(!message)\n+                        return \n+                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: message, bubbles: true }))\n                 e.target.value = ''\n                 }\n             }"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent submitting empty messages", "commit": "663ab415101e5da31fb71e3e9e3b433fbd6c3031", "diff": "commit 663ab415101e5da31fb71e3e9e3b433fbd6c3031\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:36:59 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 4fd2845..6bace32 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -46,6 +46,11 @@ class ChatInputElement extends HTMLElement {\n         })\n \n         this.container.querySelector('button').addEventListener('click', (e) => {\n+            \n+            const message = me.textBox.value.trim();\n+            if(!message){\n+                return \n+            }\n             this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n             setTimeout(()=>{\n                 me.textBox.value = ''"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added query endpoint with security checks", "commit": "3796c7c54767b5de18c5310d20c9dd3c5aafdd0c", "diff": "commit 3796c7c54767b5de18c5310d20c9dd3c5aafdd0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:46:02 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex eefc4f9..94e4d88 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -103,7 +103,15 @@ class RPCView(BaseView):\n             self._require_login()\n             return args\n \n-\n+        async def query(self,*args):\n+            self._require_login()\n+            print(args,flush=True)\n+            query = args[0] \n+            lowercase = query.lower()\n+            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n+                raise Exception(\"Not allowed\")\n+            records = [dict(record) for record in self.services.channel.query(args[0])]\n+            return records"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Made channel query asynchronous", "commit": "f6f99684307249b6650dcbbb3168db1ebfa71e73", "diff": "commit f6f99684307249b6650dcbbb3168db1ebfa71e73\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 19:47:03 2025 +0100\n\n    Word break\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 94e4d88..531aa40 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -110,7 +110,7 @@ class RPCView(BaseView):\n             lowercase = query.lower()\n             if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) for record in self.services.channel.query(args[0])]\n+            records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Disable autoescape before linkify and markdown", "commit": "0c68c4e62255a307ecb48cba011ef38ace935eb3", "diff": "commit 0c68c4e62255a307ecb48cba011ef38ace935eb3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Jan 31 22:07:01 2025 +0100\n\n    Fixed autoescape.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 20820d3..92ac639 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Allow underscores and plus signs in usernames", "commit": "4185bb3a69ac66d7b6614acf76bb5a2f613e0b82", "diff": "commit 4185bb3a69ac66d7b6614acf76bb5a2f613e0b82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 00:01:23 2025 +0100\n\n    Changed validation.\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex f611d6a..2910a6d 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -8,7 +8,7 @@ class UserModel(BaseModel):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     nick = ModelField(\n         name=\"nick\","}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added emoji support to templates and app.py", "commit": "928969b8b6266298317ea4f7ca3e6b2cfbd42e82", "diff": "commit 928969b8b6266298317ea4f7ca3e6b2cfbd42e82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 05:41:03 2025 +0100\n\n    Added emoji's\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex a2a0ccb..98207fd 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -25,6 +25,7 @@ dependencies = [\n     \"aiohttp-session\",\n     \"cryptography\",\n     \"requests\",\n-    \"asyncssh\"\n+    \"asyncssh\",\n+    \"emoji\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c2c3651..137abf0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -16,7 +16,7 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n-from snek.system.template import LinkifyExtension, PythonExtension\n+from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.index import IndexView\n@@ -54,6 +54,7 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n+        self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n         self.cache = Cache(self)\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 69222b6..ed153f0 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,6 +1,11 @@\n from types import SimpleNamespace\n from bs4 import BeautifulSoup\n import re \n+import emoji\n+\n+from jinja2 import TemplateSyntaxError, nodes\n+from jinja2.ext import Extension\n+from jinja2.nodes import Const\n \n \n \n@@ -33,9 +38,24 @@ def linkify_https(text):\n     return set_link_target_blank(str(soup))\n \n \n-from jinja2 import TemplateSyntaxError, nodes\n-from jinja2.ext import Extension\n-from jinja2.nodes import Const\n+class EmojiExtension(Extension):\n+    tags = {\"emoji\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return emoji.emojize(caller(),language='alias')\n+\n \n \n class LinkifyExtension(Extension):\n@@ -98,4 +118,4 @@ class PythonExtension(Extension):\n                 to_write.append(text)\n             exec(source)\n             return \"\".join(to_write)\n-        return str(fn(caller()))\n\\ No newline at end of file\n+        return str(fn(caller()))\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 92ac639..39ea7e8 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "refactor: Removed unnecessary timezone handling", "commit": "feeb94c9cf08ebee6d42165988b1d51030df4c33", "diff": "commit feeb94c9cf08ebee6d42165988b1d51030df4c33\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 17:39:57 2025 +0100\n\n    Removed Z\n\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 67a12c2..1272281 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -53,8 +53,6 @@ class MessageListElement extends HTMLElement {\n         return `just now`\n     }\n     timeDescription(isoDate) {\n-        if (!isoDate.endsWith(\"Z\"))\n-            isoDate += \"Z\"\n         const date = new Date(isoDate)\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n@@ -170,4 +168,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\n+customElements.define('message-list', MessageListElement);"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added highlight stylesheet link", "commit": "e0ed4491b414c51b54e4c3ebd10cbebb46a903c6", "diff": "commit e0ed4491b414c51b54e4c3ebd10cbebb46a903c6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:35:27 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 39ea7e8..42ef9fa 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1,5 @@\n     <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n     </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Add basic syntax highlighting stylesheet", "commit": "98d89dbc5f45a61ab6335e38d6e4a1df39bcc621", "diff": "commit 98d89dbc5f45a61ab6335e38d6e4a1df39bcc621\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:38:35 2025 +0100\n\n    highlight\n\ndiff --git a/src/snek/static/highlight.css b/src/snek/static/highlight.css\nnew file mode 100644\nindex 0000000..8e6fbf1\n--- /dev/null\n+++ b/src/snek/static/highlight.css\n@@ -0,0 +1,74 @@\n+pre { line-height: 125%; }\n+td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }\n+span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added highlight.css stylesheet to message template", "commit": "a06e3f404a15d8115fa65ba8533ff7774baa0beb", "diff": "commit a06e3f404a15d8115fa65ba8533ff7774baa0beb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 1 19:41:38 2025 +0100\n\n    highlight\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 42ef9fa..bcff7fb 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1,5 +1 @@\n-    <div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">\n-    </div><div class=\"time\">{{created_at}}</div></div></div>"}
{"repo": ".", "date": "2025-02-02", "line": "feat: Add user data and audio notification check", "commit": "99fc9118b37f8564cd6e211d3d77ef997592f361", "diff": "commit 99fc9118b37f8564cd6e211d3d77ef997592f361\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 2 23:14:00 2025 +0100\n\n    Fixed audio notification\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 98207fd..423a69d 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -26,6 +26,7 @@ dependencies = [\n     \"cryptography\",\n     \"requests\",\n     \"asyncssh\",\n-    \"emoji\"\n+    \"emoji\",\n+    \"pywebpush\"\n ]\n \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 44853f1..46df3df 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,81 +1,5 @@\n \n \n-    uid = null \n-    author = null\n-    avatar = null \n-    text = null \n-    time = null\n-    constructor(uid,avatar,author,text,time){\n-        this.uid = uid \n-        this.avatar = avatar \n-        this.author = author \n-        this.text = text \n-        this.time = time \n-    }\n-    \n-    get links() {\n-        if(!this.text)\n-            return []\n-        let result = []\n-        for(let part in this.text.split(/[,; ]/)){\n-            if(part.startsWith(\"http\") || part.startsWith(\"www.\") || part.indexOf(\".com\") || part.indexOf(\".net\") || part.indexOf(\".io\") || part.indexOf(\".nl\")){\n-                result.push(part)\n-\n-            }\n-        }\n-        return result\n-    }\n-    get mentions() {\n-        if(!this.text)\n-            return []\n-        let result = []\n-        for(let part in this.text.split(/[,; ]/)){\n-            if(part.startsWith(\"@\")){\n-                result.push(part)\n-\n-            }\n-        }\n-        return result \n-    }\n-\n-\n-class Messages {\n-\n-\n-\n-}\n-\n-\n-\n-\n-class Room {\n-    name = null\n-    messages = []\n-    constructor(name) {\n-        this.name = name\n-    }\n-    setMessages(list) {\n-\n-    }\n-\n-\n-}\n-\n-\n-class InlineAppElement extends HTMLElement {\n-\n-    constructor() {\n-    }\n-\n-}\n-\n-class Page {\n-    elements = []\n-\n-}\n \n class RESTClient {\n     debug = false\n@@ -378,7 +302,8 @@ class App extends EventHandler {\n     rest = rest\n     ws = null\n     rpc = null\n-    audio = null \n+    audio = null\n+    user = {}\n     constructor() {\n         super()\n         this.rooms.push(new Room(\"General\"))\n@@ -389,6 +314,7 @@ class App extends EventHandler {\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n+        this.user = await this.rpc.getUser(null)\n     }\n     playSound(index){\n         this.audio.play(index)\n@@ -417,4 +343,4 @@ class App extends EventHandler {\n \n }\n \n-const app = new App()\n\\ No newline at end of file\n+const app = new App()\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 58845b4..ce02fd3 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -6,10 +6,12 @@ class ChatWindowElement extends HTMLElement {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n-        \n+        this.app = app  \n         this.shadowRoot.appendChild(this.component);\n     }\n-\n+    get user() {\n+        return this.app.user \n+    }\n     async connectedCallback() {\n         const link = document.createElement('link')\n         link.rel = 'stylesheet'\n@@ -61,7 +63,8 @@ class ChatWindowElement extends HTMLElement {\n         })\n         const me = this\n         channelElement.addEventListener(\"message\",(message)=>{\n-            app.playSound(0)\n+            if(me.user.uid != message.detail.user_uid)\n+                app.playSound(0)\n             message.detail.element.scrollIntoView()\n          \n     })\n@@ -72,4 +75,4 @@ class ChatWindowElement extends HTMLElement {\n \n }\n \n-customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\n+customElements.define('chat-window', ChatWindowElement);"}
{"repo": ".", "date": "2025-02-02", "line": "fix: Resolve issue with user data loading and notification handling", "commit": "7d750db1f8235c8231699c2da39c1075ac678841", "diff": "commit 7d750db1f8235c8231699c2da39c1075ac678841\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 2 23:21:43 2025 +0100\n\n    Fixed own notification issue.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 46df3df..52c25a4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -298,7 +298,6 @@ class NotificationAudio {\n }\n \n class App extends EventHandler {\n-    rooms = []\n     rest = rest\n     ws = null\n     rpc = null\n@@ -306,7 +305,6 @@ class App extends EventHandler {\n     user = {}\n     constructor() {\n         super()\n-        this.rooms.push(new Room(\"General\"))\n         this.ws = new Socket()\n         this.rpc = this.ws.client\n         const me = this\n@@ -314,7 +312,10 @@ class App extends EventHandler {\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(data.channel_uid, data)\n         })\n-        this.user = await this.rpc.getUser(null)\n+\n+        this.rpc.getUser(null).then(user=>{\n+            me.user = user\n+        })\n     }\n     playSound(index){\n         this.audio.play(index)"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Improved code display with word wrapping", "commit": "3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5", "diff": "commit 3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:07:33 2025 +0100\n\n    Wrapped highlight.css\n\ndiff --git a/src/snek/static/highlight.css b/src/snek/static/highlight.css\nindex 8e6fbf1..455fa19 100644\n--- a/src/snek/static/highlight.css\n+++ b/src/snek/static/highlight.css\n@@ -1,4 +1,6 @@\n-pre { line-height: 125%; }\n+pre { line-height: 125%;  white-space: pre-wrap; \n+  word-break: break-word; \n+  overflow-wrap: break-word; }\n td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }\n span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; }"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved CSS formatting for better readability", "commit": "23c8ebca73ac49c826434d40fd1e1fd2e3435957", "diff": "commit 23c8ebca73ac49c826434d40fd1e1fd2e3435957\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:08:17 2025 +0100\n\n    Updated CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 46b9fef..1d98245 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -2,6 +2,9 @@\n   margin: 0;\n   box-sizing: border-box;\n+ white-space: pre-wrap; \n+  word-break: break-word; \n+  overflow-wrap: break-word;\n }\n \n .gallery {"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in chat messages", "commit": "38a24e9a12355f93776c9aef0b9caa5afb075531", "diff": "commit 38a24e9a12355f93776c9aef0b9caa5afb075531\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 00:10:09 2025 +0100\n\n    Updated CSS.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 1d98245..aa3615b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -2,9 +2,6 @@\n   margin: 0;\n   box-sizing: border-box;\n- white-space: pre-wrap; \n-  word-break: break-word; \n-  overflow-wrap: break-word;\n }\n \n .gallery {\n@@ -183,6 +180,10 @@ message-list {\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n+   white-space: pre-wrap;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+\n }\n \n .chat-messages .message .message-content .time {"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed unused padding from message list", "commit": "83cc0f613708ed27b928ba45b330c984d82dd546", "diff": "commit 83cc0f613708ed27b928ba45b330c984d82dd546\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:24:09 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa3615b..44b3c5c 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -147,7 +147,7 @@ message-list {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0px;\n-  padding: 5px;\n   border-radius: 8px;"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added padding to message list and hid avatar", "commit": "079187e1b460e5554bfec8b9658b5059cc3d51c6", "diff": "commit 079187e1b460e5554bfec8b9658b5059cc3d51c6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:27:21 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 44b3c5c..d8f67e7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -147,7 +147,7 @@ message-list {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0px;\n+  padding: 5px;\n   border-radius: 8px;\n@@ -248,6 +248,7 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n+      display: none;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Hide avatar on message list", "commit": "f4a5536dcf1e27a7e8319488f8f39a8acfb818a2", "diff": "commit f4a5536dcf1e27a7e8319488f8f39a8acfb818a2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:28:48 2025 +0100\n\n    Added highlight.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex d8f67e7..0599a55 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -248,7 +248,9 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n-      display: none;\n+      height: 0;\n+      padding: 0;\n+      margin: 0;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "revert: Restored avatar visibility", "commit": "fe707dca4ea0bc2ecaedcda292f1ae636fce2b93", "diff": "commit fe707dca4ea0bc2ecaedcda292f1ae636fce2b93\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 01:30:43 2025 +0100\n\n    Back to default.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0599a55..aa3615b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -248,9 +248,6 @@ message-list {\n   }\n   .avatar {\n       opacity: 0;\n-      height: 0;\n-      padding: 0;\n-      margin: 0;\n   }\n \n   .author {"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added upload button functionality and improved UI elements.", "commit": "b48a901e3385617d36511e251c4e7c62498e23bc", "diff": "commit b48a901e3385617d36511e251c4e7c62498e23bc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 20:45:29 2025 +0100\n\n    Non working upload button.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 423a69d..ed36d70 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -6,7 +6,7 @@ build-backend = \"setuptools.build_meta\"\n name = \"Snek\"\n version = \"1.0.0\"\n readme = \"README.md\"\n-license = { file = \"LICENSE\", content-type=\"text/markdown\" }\n description = \"Snek Chat Application by Molodetz\"\n authors = [\n     { name = \"retoor\", email = \"retoor@molodetz.nl\" }\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 52c25a4..ada9654 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -1,347 +1,309 @@\n \n \n+\n \n class RESTClient {\n-    debug = false\n+    debug = false;\n \n-    async get(url, params) {\n-        params = params ? params : {}\n+    async get(url, params = {}) {\n         const encodedParams = new URLSearchParams(params);\n-        if (encodedParams)\n-            url += '?' + encodedParams\n+        if (encodedParams) url += '?' + encodedParams;\n         const response = await fetch(url, {\n             method: 'GET',\n             headers: {\n-                'Content-Type': 'application/json'\n-            }\n+                'Content-Type': 'application/json',\n+            },\n         });\n-        const result = await response.json()\n+        const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url: url, params: params, result: result })\n+            console.debug({ url, params, result });\n         }\n-        return result\n+        return result;\n     }\n+\n     async post(url, data) {\n         const response = await fetch(url, {\n             method: 'POST',\n             headers: {\n-                'Content-Type': 'application/json'\n+                'Content-Type': 'application/json',\n             },\n-            body: JSON.stringify(data)\n+            body: JSON.stringify(data),\n         });\n \n-        const result = await response.json()\n+        const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url: url, params: params, result: result })\n+            console.debug({ url, data, result });\n         }\n-        return result\n+        return result;\n     }\n }\n-const rest = new RESTClient()\n \n class EventHandler {\n-\n     constructor() {\n-        this.subscribers = {}\n+        this.subscribers = {};\n     }\n+\n     addEventListener(type, handler) {\n-        if (!this.subscribers[type])\n-            this.subscribers[type] = []\n-        this.subscribers[type].push(handler)\n+        if (!this.subscribers[type]) this.subscribers[type] = [];\n+        this.subscribers[type].push(handler);\n     }\n+\n     emit(type, ...data) {\n-        if (this.subscribers[type])\n-            this.subscribers[type].forEach(handler => handler(...data))\n+        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n     }\n-\n }\n \n class Chat extends EventHandler {\n-\n     constructor() {\n-        super()\n-        this._socket = null\n-        this._wait_connect = null\n-        this._promises = {}\n+        super();\n+        this._socket = null;\n+        this._waitConnect = null;\n+        this._promises = {};\n     }\n-    connect() {\n-        if (this._wait_connect)\n-            return this._wait_connect\n \n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n-            me._wait_connect = resolve\n-            console.debug(\"Connecting..\")\n+    connect() {\n+        if (this._waitConnect) {\n+            return this._waitConnect;\n+        }\n+        return new Promise((resolve) => {\n+            this._waitConnect = resolve;\n+            console.debug(\"Connecting..\");\n \n             try {\n-                me._socket = new WebSocket(me._url)\n-            }catch(e){\n-                console.warning(e)\n-                setTimeout(()=>{\n-                    me.ensureConnection()\n-                },1000)\n-            }\n-\n-            me._socket.onconnect = () => {\n-                me._connected()\n-                me._wait_socket(me)\n+                this._socket = new WebSocket(this._url);\n+            } catch (e) {\n+                console.warn(e);\n+                setTimeout(() => {\n+                    this.ensureConnection();\n+                }, 1000);\n             }\n-        })\n \n+            this._socket.onconnect = () => {\n+                this._connected();\n+                this._waitSocket();\n+            };\n+        });\n     }\n+\n     generateUniqueId() {\n+        return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n+\n     call(method, ...args) {\n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n+        return new Promise((resolve, reject) => {\n             try {\n-                const command = { method: method, args: args, message_id: me.generateUniqueId() }\n-                me._promises[command.message_id] = resolve\n-                await me._socket.send(JSON.stringify(command))\n-\n+                const command = { method, args, message_id: this.generateUniqueId() };\n+                this._promises[command.message_id] = resolve;\n+                this._socket.send(JSON.stringify(command));\n             } catch (e) {\n-                reject(e)\n+                reject(e);\n             }\n-        })\n+        });\n     }\n+\n     _connected() {\n-        const me = this\n         this._socket.onmessage = (event) => {\n-            const message = JSON.parse(event.data)\n-            if (message.message_id && me._promises[message.message_id]) {\n-                me._promises[message.message_id](message)\n-                delete me._promises[message.message_id]\n+            const message = JSON.parse(event.data);\n+            if (message.message_id && this._promises[message.message_id]) {\n+                this._promises[message.message_id](message);\n+                delete this._promises[message.message_id];\n             } else {\n-                me.emit(\"message\", me, message)\n+                this.emit(\"message\", message);\n             }\n-        }\n-        this._socket.onclose = (event) => {\n-            me._wait_socket = null\n-            me._socket = null\n-            me.emit('close', me)\n-        }\n+        };\n+        this._socket.onclose = () => {\n+            this._waitSocket = null;\n+            this._socket = null;\n+            this.emit('close');\n+        };\n     }\n \n     async privmsg(room, text) {\n         await rest.post(\"/api/privmsg\", {\n-            room: room,\n-            text: text\n-        })\n+            room,\n+            text,\n+        });\n     }\n-\n }\n \n class Socket extends EventHandler {\n-    ws = null\n-    isConnected = null\n-    isConnecting = null\n-    url = null\n-    connectPromises = []\n-    ensureTimer = null \n+    ws = null;\n+    isConnected = null;\n+    isConnecting = null;\n+    url = null;\n+    connectPromises = [];\n+    ensureTimer = null;\n+\n     constructor() {\n-        super()\n-        this.ensureConnection()\n+        super();\n+        this.ensureConnection();\n     }\n+\n     _camelToSnake(str) {\n-        return str\n-            .replace(/([a-z])([A-Z])/g, '$1_$2')\n-            .toLowerCase();\n+        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n     }\n+\n     get client() {\n-        const me = this\n-        const proxy = new Proxy(\n-            {},\n-            {\n-                get(target, prop) {\n-                    return (...args) => {\n-                        let functionName = me._camelToSnake(prop)\n-                        return me.call(functionName, ...args);\n-                    };\n-                },\n-            }\n-        );\n-        return proxy\n+        const me = this;\n+        return new Proxy({}, {\n+            get(_, prop) {\n+                return (...args) => {\n+                    const functionName = me._camelToSnake(prop);\n+                    return me.call(functionName, ...args);\n+                };\n+            },\n+        });\n     }\n+\n     ensureConnection() {\n-        if(this.ensureTimer)\n-            return this.connect()\n-        const me  = this \n-        this.ensureTimer = setInterval(()=>{\n-            if (me.isConnecting)\n-                me.isConnecting = false\n-            me.connect()\n-        },5000)\n-        return this.connect()\n+        if (this.ensureTimer) {\n+            return this.connect();\n+        }\n+        this.ensureTimer = setInterval(() => {\n+            if (this.isConnecting) this.isConnecting = false;\n+            this.connect();\n+        }, 5000);\n+        return this.connect();\n     }\n+\n     generateUniqueId() {\n         return 'id-' + Math.random().toString(36).substr(2, 9);\n     }\n+\n     connect() {\n-        const me = this\n-        if (!this.isConnected && !this.isConnecting) {\n-            this.isConnecting = true\n-        } else if (this.isConnecting) {\n-            return new Promise((resolve, reject) => {\n-                me.connectPromises.push(resolve)\n-            })\n-        } else if (this.isConnected) {\n-            return new Promise((resolve, reject) => {\n-                resolve(me)\n-            })\n+        if (this.isConnected || this.isConnecting) {\n+            return new Promise((resolve) => {\n+                this.connectPromises.push(resolve);\n+                if (!this.isConnected) resolve(this);\n+            });\n         }\n-        return new Promise((resolve, reject) => {\n-            me.connectPromises.push(resolve)\n-            console.debug(\"Connecting..\")\n-            \n-            const ws = new WebSocket(this.url)\n-               \n-                ws.onopen = (event) => {\n-                me.ws = ws\n-                me.isConnected = true\n-                me.isConnecting = false\n+        this.isConnecting = true;\n+        return new Promise((resolve) => {\n+            this.connectPromises.push(resolve);\n+            console.debug(\"Connecting..\");\n+\n+            const ws = new WebSocket(this.url);\n+            ws.onopen = () => {\n+                this.ws = ws;\n+                this.isConnected = true;\n+                this.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    me.onData(JSON.parse(event.data))\n-                }\n-                ws.onclose = (event) => {\n-                    me.onClose()\n-\n-                }\n-                ws.onerror = (event)=>{\n-                    me.onClose()\n-                }\n-                me.connectPromises.forEach(resolve => {\n-                    resolve(me)\n-                })\n-            }\n-        })\n+                    this.onData(JSON.parse(event.data));\n+                };\n+                ws.onclose = () => {\n+                    this.onClose();\n+                };\n+                ws.onerror = () => {\n+                    this.onClose();\n+                };\n+                this.connectPromises.forEach(resolver => resolver(this));\n+            };\n+        });\n     }\n+\n     onData(data) {\n-        if(data.success != undefined && !data.success){\n-            console.error(data)\n+        if (data.success !== undefined && !data.success) {\n+            console.error(data);\n         }\n-\n         if (data.callId) {\n-            this.emit(data.callId, data.data)\n+            this.emit(data.callId, data.data);\n         }\n         if (data.channel_uid) {\n-            this.emit(data.channel_uid, data.data)\n-            this.emit(\"channel-message\", data)\n+            this.emit(data.channel_uid, data.data);\n+            this.emit(\"channel-message\", data);\n         }\n-\n     }\n+\n     async sendJson(data) {\n-        return await this.connect().then((api) => {\n-            api.ws.send(JSON.stringify(data))\n-        })\n+        await this.connect().then(api => {\n+            api.ws.send(JSON.stringify(data));\n+        });\n     }\n \n     async call(method, ...args) {\n         const call = {\n             callId: this.generateUniqueId(),\n-            method: method,\n-            args: args\n-        }\n-\n-        const me = this\n-        return new Promise(async (resolve, reject) => {\n-            me.addEventListener(call.callId, (data) => {\n-                resolve(data)\n-            })\n-            await me.sendJson(call)\n-\n-\n-        })\n+            method,\n+            args,\n+        };\n+        return new Promise((resolve) => {\n+            this.addEventListener(call.callId, data => resolve(data));\n+            this.sendJson(call);\n+        });\n     }\n+\n     onClose() {\n-        console.info(\"Connection lost. Reconnecting.\")\n-        this.isConnected = false\n-        this.isConnecting = false\n+        console.info(\"Connection lost. Reconnecting.\");\n+        this.isConnected = false;\n+        this.isConnecting = false;\n         this.ensureConnection().then(() => {\n-            console.info(\"Reconnected.\")\n-        })\n+            console.info(\"Reconnected.\");\n+        });\n     }\n-\n }\n \n class NotificationAudio {\n-    constructor(timeout){\n-        if(!timeout)\n-            timeout = 500\n-        this.schedule = new Schedule(timeout)\n+    constructor(timeout = 500) {\n+        this.schedule = new Schedule(timeout);\n     }\n-    sounds = [\"/audio/soundfx.d_beep3.mp3\"]\n-    play(soundIndex) {\n-        this.schedule.delay(() => {\n-            \n-        \n-\n-        if (!soundIndex)\n-            soundIndex = 0\n \n-        const player = new Audio(this.sounds[soundIndex]);\n+    sounds = [\"/audio/soundfx.d_beep3.mp3\"];\n \n-        player.play()\n-            .then(() => {\n-                console.debug(\"Gave sound notification\")\n-            })\n-            .catch((error) => {\n-                console.error(\"Notification failed:\", error);\n-            });\n-        })\n+    play(soundIndex = 0) {\n+        this.schedule.delay(() => {\n+            new Audio(this.sounds[soundIndex]).play()\n+                .then(() => {\n+                    console.debug(\"Gave sound notification\");\n+                })\n+                .catch(error => {\n+                    console.error(\"Notification failed:\", error);\n+                });\n+        });\n     }\n }\n \n class App extends EventHandler {\n-    rest = rest\n-    ws = null\n-    rpc = null\n-    audio = null\n-    user = {}\n+    rest = new RESTClient();\n+    ws = null;\n+    rpc = null;\n+    audio = null;\n+    user = {};\n+\n     constructor() {\n-        super()\n-        this.ws = new Socket()\n-        this.rpc = this.ws.client\n-        const me = this\n-        this.audio = new NotificationAudio(500)\n+        super();\n+        this.ws = new Socket();\n+        this.rpc = this.ws.client;\n+        this.audio = new NotificationAudio(500);\n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid, data)\n-        })\n+            this.emit(data.channel_uid, data);\n+        });\n \n-        this.rpc.getUser(null).then(user=>{\n-            me.user = user\n-        })\n+        this.rpc.getUser(null).then(user => {\n+            this.user = user;\n+        });\n     }\n-    playSound(index){\n-        this.audio.play(index)\n+\n+    playSound(index) {\n+        this.audio.play(index);\n     }\n-    async benchMark(times, message) {\n-        if (!times)\n-            times = 100\n-        if (!message)\n-            message = \"Benchmark Message\"\n-        let promises = []\n-        const me = this\n+\n+    async benchMark(times = 100, message = \"Benchmark Message\") {\n+        const promises = [];\n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n-                    me.rpc.sendMessage(channel.uid, `${message} ${i}`).then(data => {\n-\n-                    })\n-                })\n-            }))\n-\n+                    this.rpc.sendMessage(channel.uid, `${message} ${i}`);\n+                });\n+            }));\n         }\n-\n     }\n-\n-\n }\n \n-const app = new App()\n+const app = new App();\n\\ No newline at end of file\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa3615b..2393a62 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -209,7 +209,7 @@ message-list {\n   resize: none;\n }\n \n-.chat-input button {\n+.chat-input upload-button {\n   color: white;\n   border: none;\n@@ -240,11 +240,16 @@ message-list {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n+  white-space: pre-wrap; \n   hyphens: auto;\n     img {\n       max-width: 90%;\n       border-radius: 20px;\n     }\n+    {\n+        padding: 0;\n+        margin: 0;\n+    }\n   }\n   .avatar {\n       opacity: 0;\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 6bace32..6a9353c 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -1,63 +1,59 @@\n \n+\n+\n \n class ChatInputElement extends HTMLElement {\n-    constructor() {\n-        super();\n-        this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div');\n-        this.shadowRoot.appendChild(this.component);\n-    }\n-    connectedCallback() {\n-        const me = this\n-        const link = document.createElement(\"link\")\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.container = document.createElement('div')\n-        this.container.classList.add(\"chat-input\")\n-        this.container.innerHTML = `\n-            <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-            <button>Send</button>\n-        `;\n-        this.textBox = this.container.querySelector('textarea')\n-        this.textBox.addEventListener('input', (e) => {\n-            this.dispatchEvent(new CustomEvent(\"input\", { detail: e.target.value, bubbles: true }))\n-            const message = e.target.value;\n-            const button = this.container.querySelector('button');\n-            button.disabled = !message;\n-        })\n-        this.textBox.addEventListener('change', (e) => {\n-            e.preventDefault()\n-            this.dispatchEvent(new CustomEvent(\"change\", { detail: e.target.value, bubbles: true }))\n-            console.error(e.target.value)\n-        })\n-        this.textBox.addEventListener('keydown', (e) => {\n-\n-            if (e.key == 'Enter') {\n-                if(!e.shiftKey){\n-                    e.preventDefault()\n-                    const message = e.target.value.trim();\n-                    if(!message)\n-                        return \n-                    this.dispatchEvent(new CustomEvent(\"submit\", { detail: message, bubbles: true }))\n-                e.target.value = ''\n-                }\n-            }\n-        })\n-\n-        this.container.querySelector('button').addEventListener('click', (e) => {\n-            \n-            const message = me.textBox.value.trim();\n-            if(!message){\n-                return \n-            }\n-            this.dispatchEvent(new CustomEvent(\"submit\", { detail: me.textBox.value, bubbles: true }))\n-            setTimeout(()=>{\n-                me.textBox.value = ''\n-                me.textBox.focus()\n-            },200)\n-        })\n-        this.component.appendChild(this.container)\n-    }\n+  \n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.component = document.createElement('div');\n+    this.shadowRoot.appendChild(this.component);\n+  }\n+\n+  connectedCallback() {\n+    const link = document.createElement('link');\n+    link.rel = 'stylesheet';\n+    link.href = '/base.css';\n+    this.component.appendChild(link);\n+\n+    this.container = document.createElement('div');\n+    this.container.classList.add('chat-input');\n+    this.container.innerHTML = `\n+      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+      <upload-button></upload-button>\n+    `;\n+    this.textBox = this.container.querySelector('textarea');\n+\n+    this.textBox.addEventListener('input', (e) => {\n+      this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n+      const message = e.target.value;\n+      const button = this.container.querySelector('button');\n+      button.disabled = !message;\n+    });\n+\n+    this.textBox.addEventListener('change', (e) => {\n+      e.preventDefault();\n+      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+      console.error(e.target.value);\n+    });\n+\n+    this.textBox.addEventListener('keydown', (e) => {\n+      if (e.key === 'Enter' && !e.shiftKey) {\n+        e.preventDefault();\n+        const message = e.target.value.trim();\n+        if (!message) return;\n+        this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));\n+        e.target.value = '';\n+      }\n+    });\n+\n+    this.component.appendChild(this.container);\n+  }\n }\n-customElements.define('chat-input', ChatInputElement);\n+\n+customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex ce02fd3..2c2973a 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -1,78 +1,78 @@\n \n+\n+\n \n class ChatWindowElement extends HTMLElement {\n-    receivedHistory = false\n+    receivedHistory = false;\n+\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('section');\n-        this.app = app  \n+        this.app = app;\n         this.shadowRoot.appendChild(this.component);\n     }\n+\n     get user() {\n-        return this.app.user \n+        return this.app.user;\n     }\n+\n     async connectedCallback() {\n-        const link = document.createElement('link')\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.component.classList.add(\"chat-area\")\n-        this.container = document.createElement(\"section\")\n-        this.container.classList.add(\"chat-area\")\n-        this.container.classList.add(\"chat-window\")\n-        \n-        const chatHeader = document.createElement(\"div\")\n-        chatHeader.classList.add(\"chat-header\")\n+        const link = document.createElement('link');\n+        link.rel = 'stylesheet';\n+        link.href = '/base.css';\n+        this.component.appendChild(link);\n+        this.component.classList.add(\"chat-area\");\n         \n+        this.container = document.createElement(\"section\");\n+        this.container.classList.add(\"chat-area\", \"chat-window\");\n \n-        \n-        \n-        const chatTitle = document.createElement('h2')\n-        chatTitle.classList.add(\"chat-title\")\n-        chatTitle.innerText = \"Loading...\"\n-        chatHeader.appendChild(chatTitle)\n-        this.container.appendChild(chatHeader)\n-        const channels = await app.rpc.getChannels()\n-        const channel = channels[0]\n-        chatTitle.innerText = channel.name \n-        const channelElement = document.createElement('message-list')\n-        channelElement.setAttribute(\"channel\", channel.uid)\n-        this.container.appendChild(channelElement)\n-        const chatInput = document.createElement('chat-input')\n-        \n-        chatInput.addEventListener(\"submit\",(e)=>{\n-            app.rpc.sendMessage(channel.uid,e.detail)\n-        })\n-        this.container.appendChild(chatInput)\n-\n-        this.component.appendChild(this.container)\n-        const messages = await app.rpc.getMessages(channel.uid)\n-        messages.forEach(message=>{\n-            if(!message['user_nick'])\n-                return\n-            channelElement.addMessage(message)\n-        })\n-        const me = this\n-        channelElement.addEventListener(\"message\",(message)=>{\n-            if(me.user.uid != message.detail.user_uid)\n-                app.playSound(0)\n-            message.detail.element.scrollIntoView()\n-         \n-    })\n+        const chatHeader = document.createElement(\"div\");\n+        chatHeader.classList.add(\"chat-header\");\n \n-        \n-    }\n+        const chatTitle = document.createElement('h2');\n+        chatTitle.classList.add(\"chat-title\");\n+        chatTitle.innerText = \"Loading...\";\n+        chatHeader.appendChild(chatTitle);\n+        this.container.appendChild(chatHeader);\n+\n+        const channels = await app.rpc.getChannels();\n+        const channel = channels[0];\n+        chatTitle.innerText = channel.name;\n \n+        const channelElement = document.createElement('message-list');\n+        channelElement.setAttribute(\"channel\", channel.uid);\n+        this.container.appendChild(channelElement);\n \n+        const chatInput = document.createElement('chat-input');\n+        chatInput.addEventListener(\"submit\", (e) => {\n+            app.rpc.sendMessage(channel.uid, e.detail);\n+        });\n+        this.container.appendChild(chatInput);\n+\n+        this.component.appendChild(this.container);\n+\n+        const messages = await app.rpc.getMessages(channel.uid);\n+        messages.forEach(message => {\n+            if (!message['user_nick']) return;\n+            channelElement.addMessage(message);\n+        });\n+\n+        const me = this;\n+        channelElement.addEventListener(\"message\", (message) => {\n+            if (me.user.uid !== message.detail.user_uid) app.playSound(0);\n+            message.detail.element.scrollIntoView();\n+        });\n+    }\n }\n \n-customElements.define('chat-window', ChatWindowElement);\n+customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 5eec215..948db17 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -1,30 +1,33 @@\n+\n+\n+\n+\n \n \n class FancyButton extends HTMLElement {\n-    url = null\n-    type=\"button\"\n-    value = null\n-    constructor(){\n-        super()\n-        this.attachShadow({mode:'open'})\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.url = null;\n+        this.type = \"button\";\n+        this.value = null;\n     }\n \n     connectedCallback() {\n+        this.container = document.createElement('span');\n+        let size = this.getAttribute('size');\n+        console.info({ GG: size });\n+        size = size === 'auto' ? '1%' : '33%';\n \n-        this.container = document.createElement('span')\n-        let size = this.getAttribute('size')\n-        console.info({GG:size})\n-        if(size == 'auto'){\n-           size = '1%' \n-        }else{\n-            size = '33%'\n-        }\n-        this.styleElement = document.createElement(\"style\")\n+        this.styleElement = document.createElement(\"style\");\n         this.styleElement.innerHTML = `\n         :root {\n-        width:100%;\n+            width: 100%;\n             --width: 100%;\n-    }\n+        }\n         button {\n             width: var(--width);\n             min-width: ${size};\n@@ -37,32 +40,31 @@ class FancyButton extends HTMLElement {\n             font-weight: bold;\n             cursor: pointer;\n             transition: background-color 0.3s;\n-\n-    }\n+        }\n         button:hover {\n-  }\n-    `\n-    this.container.appendChild(this.styleElement)\n-        this.buttonElement = document.createElement('button')\n-        this.container.appendChild(this.buttonElement)\n-        this.shadowRoot.appendChild(this.container)\n-    \n+        }\n+        `;\n+\n+        this.container.appendChild(this.styleElement);\n+        this.buttonElement = document.createElement('button');\n+        this.container.appendChild(this.buttonElement);\n+        this.shadowRoot.appendChild(this.container);\n+\n         this.url = this.getAttribute('url');\n-        this.value = this.getAttribute('value')\n-        const me = this \n-        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")))\n-        this.buttonElement.addEventListener(\"click\",()=>{\n-            if(me.url == \"/back\" || me.url == \"/back/\"){\n-                window.history.back()\n-            }else if(me.url){\n-                window.location = me.url\n+        this.value = this.getAttribute('value');\n+        this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")));\n+        this.buttonElement.addEventListener(\"click\", () => {\n+            if (this.url === \"/back\" || this.url === \"/back/\") {\n+                window.history.back();\n+            } else if (this.url) {\n+                window.location = this.url;\n             }\n-        })\n+        });\n     }\n }\n \n-customElements.define(\"fancy-button\",FancyButton)\n+customElements.define(\"fancy-button\", FancyButton);\n\\ No newline at end of file\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex e775bf4..730d70a 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -1,346 +1,362 @@\n+\n+\n+\n \n class GenericField extends HTMLElement {\n-  form = null\n-  field = null \n-  inputElement = null\n-  footerElement = null \n-  action = null \n-  container = null\n-  styleElement = null\n-  name = null\n-    get value() {\n-      return this.inputElement.value\n-    }\n-    get type() {\n+  form = null;\n+  field = null;\n+  inputElement = null;\n+  footerElement = null;\n+  action = null;\n+  container = null;\n+  styleElement = null;\n+  name = null;\n \n-        return this.field.tag \n-    }\n-    set value(val) {\n-      val = val == null ? '' : val \n-      this.inputElement.value = val \n-      this.inputElement.setAttribute(\"value\", val)\n-    }\n-    setInvalid(){\n-      this.inputElement.classList.add(\"error\")\n-      this.inputElement.classList.remove(\"valid\")\n-    }\n-    setErrors(errors){\n-      if(errors.length)\n-        this.inputElement.setAttribute(\"title\", errors[0])\n-      else\n-       this.inputElement.setAttribute(\"title\",\"\")\n-    }\n-    setValid(){\n-      this.inputElement.classList.remove(\"error\")\n-      this.inputElement.classList.add(\"valid\")\n-    }\n-    constructor() {\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.container = document.createElement('div')\n-        this.styleElement = document.createElement('style')\n-        this.styleElement.innerHTML = `\n-\n-            h1 {\n-              font-size: 2em;\n-              margin-bottom: 20px;\n-              margin-top: 0px;\n-            }\n+  get value() {\n+    return this.inputElement.value;\n+  }\n \n-            input {\n-                width: 90%;\n-                padding: 10px;\n-                margin: 10px 0;\n-                border-radius: 5px;\n-                font-size: 1em;\n-            }\n+  get type() {\n+    return this.field.tag;\n+  }\n \n-            button {\n-                width: 50%;\n-                padding: 10px;\n-                border: none;\n-               float: right;\n-                margin-top: 10px;\n-                margin-left: 10px;\n-                margin-right: 10px;\n-                border-radius: 5px;\n-                color: white;\n-                font-size: 1em;\n-                font-weight: bold;\n-                cursor: pointer;\n-                transition: background-color 0.3s;\n-                clear: both;\n-            }\n+  set value(val) {\n+    val = val ?? '';\n+    this.inputElement.value = val;\n+    this.inputElement.setAttribute(\"value\", val);\n+  }\n \n-            button:hover {\n-            }\n+  setInvalid() {\n+    this.inputElement.classList.add(\"error\");\n+    this.inputElement.classList.remove(\"valid\");\n+  }\n \n-            a {\n-                text-decoration: none;\n-                display: block;\n-                margin-top: 15px;\n-                font-size: 0.9em;\n-                transition: color 0.3s;\n-            }\n+  setErrors(errors) {\n+    const errorText = errors.length ? errors[0] : \"\";\n+    this.inputElement.setAttribute(\"title\", errorText);\n+  }\n \n-            a:hover {\n-            }\n-            .valid {\n-                border: 1px solid green;\n-                color:green;\n-                font-size: 0.9em;\n-                margin-top: 5px;\n-            }\n-            .error {\n-              border: 3px solid red;\n-                font-size: 0.9em;\n-                margin-top: 5px;\n-            }\n-            @media (max-width: 500px) {\n-                input {\n-                    width: 90%;\n-                }\n-            }    \n-            \n-            `\n-        this.container.appendChild(this.styleElement)\n-        \n-        this.shadowRoot.appendChild(this.container)\n-    }\n-    connectedCallback(){\n+  setValid() {\n+    this.inputElement.classList.remove(\"error\");\n+    this.inputElement.classList.add(\"valid\");\n+  }\n \n-      this.updateAttributes() \n-       \n-    }\n-    setAttribute(name,value){\n-        this[name] = value \n-    }\n-    updateAttributes(){\n-      if(this.inputElement == null && this.field){\n-       this.inputElement = document.createElement(this.field.tag)\n-       if(this.field.tag == 'button'){\n-          if(this.field.value == \"submit\"){\n-          \n-          \n-          }\n-          this.action = this.field.value\n-       }\n-       this.inputElement.name = this.field.name \n-       this.name = this.inputElement.name\n-       const me = this\n-       this.inputElement.addEventListener(\"keyup\",(e)=>{\n-        if(e.key == 'Enter'){\n-            me.dispatchEvent(new Event(\"submit\"))\n-          }else if(me.field.value != e.target.value)\n-          {\n-            const event = new CustomEvent(\"change\", {detail:me,bubbles:true})\n-            me.dispatchEvent(event)\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.container = document.createElement('div');\n+    this.styleElement = document.createElement('style');\n+    this.styleElement.innerHTML = `\n+\n+      h1 {\n+        font-size: 2em;\n+        margin-bottom: 20px;\n+        margin-top: 0px;\n+      }\n+\n+      input {\n+          width: 90%;\n+          padding: 10px;\n+          margin: 10px 0;\n+          border-radius: 5px;\n+          font-size: 1em;\n+      }\n+\n+      button {\n+          width: 50%;\n+          padding: 10px;\n+          border: none;\n+          float: right;\n+          margin-top: 10px;\n+          margin-left: 10px;\n+          margin-right: 10px;\n+          border-radius: 5px;\n+          color: white;\n+          font-size: 1em;\n+          font-weight: bold;\n+          cursor: pointer;\n+          transition: background-color 0.3s;\n+          clear: both;\n+      }\n+\n+      button:hover {\n+      }\n+\n+      a {\n+          text-decoration: none;\n+          display: block;\n+          margin-top: 15px;\n+          font-size: 0.9em;\n+          transition: color 0.3s;\n+      }\n+\n+      a:hover {\n+      }\n+\n+      .valid {\n+          border: 1px solid green;\n+          color: green;\n+          font-size: 0.9em;\n+          margin-top: 5px;\n+      }\n+\n+      .error {\n+        border: 3px solid red;\n+          font-size: 0.9em;\n+          margin-top: 5px;\n+      }\n+\n+      @media (max-width: 500px) {\n+          input {\n+              width: 90%;\n           }\n-       })\n-       this.inputElement.addEventListener(\"click\",(e)=>{\n-          const event = new CustomEvent(\"click\",{detail:me,bubbles:true})\n-         me.dispatchEvent(event)    \n-       })\n-        this.container.appendChild(this.inputElement)\n+      }\n+    `;\n+    this.container.appendChild(this.styleElement);\n \n-}\n-      if(!this.field){\n-        return\n+    this.shadowRoot.appendChild(this.container);\n+  }\n+\n+  connectedCallback() {\n+    this.updateAttributes();\n+  }\n+\n+  setAttribute(name, value) {\n+    this[name] = value;\n+  }\n+\n+  updateAttributes() {\n+    if (this.inputElement == null && this.field) {\n+      this.inputElement = document.createElement(this.field.tag);\n+      if (this.field.tag === 'button' && this.field.value === \"submit\") {\n+        this.action = this.field.value;\n       }\n-        this.inputElement.setAttribute(\"type\",this.field.type == null ? 'input' : this.field.type)\n-        this.inputElement.setAttribute(\"name\",this.field.name  == null ? '' : this.field.name)\n-        \n-        if(this.field.text != null){\n-          this.inputElement.innerText = this.field.text \n-        }\n-        if(this.field.html != null){\n-          this.inputElement.innerHTML = this.field.html\n-        }\n-        if(this.field.class_name){\n-          this.inputElement.classList.add(this.field.class_name)\n-        }\n-        this.inputElement.setAttribute(\"tabindex\", this.field.index)\n-        this.inputElement.classList.add(this.field.name)\n-        this.value = this.field.value\n-        let place_holder = null \n-        if(this.field.place_holder)\n-            place_holder = this.field.place_holder\n-        if(this.field.required && place_holder){\n-          place_holder = place_holder\n-        }\n-        if(place_holder)\n-          this.field.place_holder = \"* \" + place_holder\n-        this.inputElement.setAttribute(\"placeholder\",place_holder)\n-        if(this.field.required)\n-            this.inputElement.setAttribute(\"required\",\"required\")\n-        else\n-            this.inputElement.removeAttribute(\"required\")\n-        if(!this.footerElement){\n-            this.footerElement = document.createElement('div')\n-            this.footerElement.style.clear = 'both'\n-            this.container.appendChild(this.footerElement)\n+      this.inputElement.name = this.field.name;\n+      this.name = this.inputElement.name;\n+\n+      const me = this;\n+      this.inputElement.addEventListener(\"keyup\", (e) => {\n+        if (e.key === 'Enter') {\n+          me.dispatchEvent(new Event(\"submit\"));\n+        } else if (me.field.value !== e.target.value) {\n+          const event = new CustomEvent(\"change\", { detail: me, bubbles: true });\n+          me.dispatchEvent(event);\n         }\n+      });\n+\n+      this.inputElement.addEventListener(\"click\", (e) => {\n+        const event = new CustomEvent(\"click\", { detail: me, bubbles: true });\n+        me.dispatchEvent(event);\n+      });\n+\n+      this.container.appendChild(this.inputElement);\n+    }\n+\n+    if (!this.field) {\n+      return;\n+    }\n+\n+    this.inputElement.setAttribute(\"type\", this.field.type ?? 'input');\n+    this.inputElement.setAttribute(\"name\", this.field.name ?? '');\n+\n+    if (this.field.text != null) {\n+      this.inputElement.innerText = this.field.text;\n+    }\n+    if (this.field.html != null) {\n+      this.inputElement.innerHTML = this.field.html;\n     }\n+    if (this.field.class_name) {\n+      this.inputElement.classList.add(this.field.class_name);\n+    }\n+    this.inputElement.setAttribute(\"tabindex\", this.field.index);\n+    this.inputElement.classList.add(this.field.name);\n+    this.value = this.field.value;\n+\n+    let place_holder = this.field.place_holder ?? null;\n+    if (this.field.required && place_holder) {\n+      place_holder = \"* \" + place_holder;\n+    }\n+    this.inputElement.setAttribute(\"placeholder\", place_holder);\n+    if (this.field.required) {\n+      this.inputElement.setAttribute(\"required\", \"required\");\n+    } else {\n+      this.inputElement.removeAttribute(\"required\");\n+    }\n+    if (!this.footerElement) {\n+      this.footerElement = document.createElement('div');\n+      this.footerElement.style.clear = 'both';\n+      this.container.appendChild(this.footerElement);\n+    }\n+  }\n }\n \n customElements.define('generic-field', GenericField);\n \n class GenericForm extends HTMLElement {\n-  fields = {}  \n-  form = {}\n-  constructor() {\n+  fields = {};\n+  form = {};\n \n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.styleElement = document.createElement(\"style\");\n+    this.styleElement.innerHTML = `\n \n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.styleElement = document.createElement(\"style\")\n-      this.styleElement.innerHTML = `\n-\n-      * {\n-          margin: 0;\n-          padding: 0;\n-          box-sizing: border-box;\n-          width:90%\n+    * {\n+        margin: 0;\n+        padding: 0;\n+        box-sizing: border-box;\n+        width: 90%;\n+    }\n \n+    div {\n+        border-radius: 10px;\n+        padding: 30px;\n+        width: 400px;\n+        box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n+        text-align: center;\n+    }\n+    @media (max-width: 500px) {\n+      width: 100%;\n+      height: 100%;\n+      form {\n+          height: 100%;\n+          width: 80%;\n       }\n+    }`;\n \n-      div {\n-          \n-          border-radius: 10px;\n-          padding: 30px;\n-          width: 400px;\n-          box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n-          text-align: center;\n+    this.container = document.createElement('div');\n+    this.container.appendChild(this.styleElement);\n+    this.container.classList.add(\"generic-form-container\");\n+    this.shadowRoot.appendChild(this.container);\n+  }\n \n+  connectedCallback() {\n+    const url = this.getAttribute('url');\n+    if (url) {\n+      const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n+      if (!url.startsWith(\"/\")) {\n+        fullUrl.searchParams.set('url', url);\n       }\n-      @media (max-width: 500px) {\n-        width:100%;\n-        height:100%;\n-          form {\n-              height:100%;\n-              width: 100%;\n-              width: 80%;\n-          }\n-      }`\n-      \n-      this.container = document.createElement('div');\n-      this.container.appendChild(this.styleElement)\n-      this.container.classList.add(\"generic-form-container\")\n-      this.shadowRoot.appendChild(this.container);\n+      this.loadForm(fullUrl.toString());\n+    } else {\n+      this.container.textContent = \"No URL provided!\";\n     }\n+  }\n \n-    connectedCallback() {\n-      const url = this.getAttribute('url');\n-      if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadForm(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No URL provided!\";\n+  async loadForm(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n       }\n-    }\n+      this.form = await response.json();\n \n-    async loadForm(url) {\n-      const me = this \n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n-        }\n-        me.form = await response.json();\n-        \n-        let fields = Object.values(me.form.fields)\n-        \n-        fields = fields.sort((a,b)=>{\n-          console.info(a.index,b.index)\n-          return a.index - b.index\n-        })  \n-        fields.forEach(field=>{\n-          const fieldElement = document.createElement('generic-field')\n-          me.fields[field.name] = fieldElement \n-          fieldElement.setAttribute(\"form\", me)\n-          fieldElement.setAttribute(\"field\", field)\n-          me.container.appendChild(fieldElement)\n-          fieldElement.updateAttributes() \n-          fieldElement.addEventListener(\"change\",(e)=>{\n-            me.form.fields[e.detail.name].value = e.detail.value\n-          })\n-          fieldElement.addEventListener(\"click\",async (e)=>{\n-            if(e.detail.type == \"button\"){\n-              if(e.detail.value == \"submit\")\n-              {\n-                const isValid = await me.validate()\n-                if(isValid){\n-                  const saveResult = await me.submit()\n-                  if(saveResult.redirect_url){\n-                    window.location.pathname = saveResult.redirect_url\n-                  }\n-                }\n+      let fields = Object.values(this.form.fields);\n+\n+      fields.sort((a, b) => a.index - b.index);\n+      fields.forEach(field => {\n+        const fieldElement = document.createElement('generic-field');\n+        this.fields[field.name] = fieldElement;\n+        fieldElement.setAttribute(\"form\", this);\n+        fieldElement.setAttribute(\"field\", field);\n+        this.container.appendChild(fieldElement);\n+        fieldElement.updateAttributes();\n+\n+        fieldElement.addEventListener(\"change\", (e) => {\n+          this.form.fields[e.detail.name].value = e.detail.value;\n+        });\n+\n+        fieldElement.addEventListener(\"click\", async (e) => {\n+          if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+            const isValid = await this.validate();\n+            if (isValid) {\n+              const saveResult = await this.submit();\n+              if (saveResult.redirect_url) {\n+                window.location.pathname = saveResult.redirect_url;\n               }\n             }\n-              \n-          })\n-        })\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n-      }\n-    }\n-    async validate(){\n-      const url = this.getAttribute(\"url\")\n-      const me = this\n-      let response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify({\"action\":\"validate\", \"form\":me.form})\n+          }\n+        });\n       });\n \n-      \n-      const form = await response.json()\n-      Object.values(form.fields).forEach(field=>{\n-        if(!me.form.fields[field.name])\n-          return\n-          me.form.fields[field.name].is_valid = field.is_valid \n-        if(!field.is_valid){\n-          me.fields[field.name].setInvalid()\n-          me.fields[field.name].setErrors(field.errors)\n-        }else{\n-          me.fields[field.name].setValid()\n-        }\n-        me.fields[field.name].setAttribute(\"field\",field)\n-        me.fields[field.name].updateAttributes()\n-      })\n-      Object.values(form.fields).forEach(field=>{\n-        me.fields[field.name].setErrors(field.errors)\n-      })\n-      return form['is_valid']\n-    }\n-    async submit(){\n-      const me = this \n-      const url = me.getAttribute(\"url\")\n-      const response = await fetch(url,{\n-        method: 'POST',\n-        headers: {\n-          'Content-Type': 'application/json'\n-        },\n-        body: JSON.stringify({\"action\":\"submit\", \"form\":me.form})\n-      });\n-      return await response.json()\n-    \n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n     }\n-    \n   }\n-  customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\n+\n+  async validate() {\n+    const url = this.getAttribute(\"url\");\n+\n+    let response = await fetch(url, {\n+      method: 'POST',\n+      headers: {\n+        'Content-Type': 'application/json'\n+      },\n+      body: JSON.stringify({ \"action\": \"validate\", \"form\": this.form })\n+    });\n+\n+    const form = await response.json();\n+    Object.values(form.fields).forEach(field => {\n+      if (!this.form.fields[field.name]) {\n+        return;\n+      }\n+      this.form.fields[field.name].is_valid = field.is_valid;\n+      if (!field.is_valid) {\n+        this.fields[field.name].setInvalid();\n+        this.fields[field.name].setErrors(field.errors);\n+      } else {\n+        this.fields[field.name].setValid();\n+      }\n+      this.fields[field.name].setAttribute(\"field\", field);\n+      this.fields[field.name].updateAttributes();\n+    });\n+    Object.values(form.fields).forEach(field => {\n+      this.fields[field.name].setErrors(field.errors);\n+    });\n+    return form['is_valid'];\n+  }\n+\n+  async submit() {\n+    const url = this.getAttribute(\"url\");\n+    const response = await fetch(url, {\n+      method: 'POST',\n+      headers: {\n+        'Content-Type': 'application/json'\n+      },\n+      body: JSON.stringify({ \"action\": \"submit\", \"form\": this.form })\n+    });\n+    return await response.json();\n+  }\n+}\n+\n+customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\ndiff --git a/src/snek/static/html-frame.js b/src/snek/static/html-frame.js\nindex 61369e3..0328d78 100644\n--- a/src/snek/static/html-frame.js\n+++ b/src/snek/static/html-frame.js\n@@ -1,47 +1,54 @@\n+\n+\n+\n+\n class HTMLFrame extends HTMLElement {\n     constructor() {\n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.container = document.createElement('div');\n-      this.shadowRoot.appendChild(this.container);\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.container = document.createElement('div');\n+        this.shadowRoot.appendChild(this.container);\n     }\n \n     connectedCallback() {\n-        this.container.classList.add(\"html_frame\")\n-      let url = this.getAttribute('url');\n-      if(!url.startsWith(\"https\")){\n-      }\n-      if (url) {\n-        let fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        \n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadAndRender(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No source URL!\";\n-      }\n+        this.container.classList.add(\"html_frame\");\n+        let url = this.getAttribute('url');\n+        if (!url.startsWith(\"https\")) {\n+        }\n+        if (url) {\n+            const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n+            if (!url.startsWith(\"/\")) {\n+                fullUrl.searchParams.set('url', url);\n+            }\n+            this.loadAndRender(fullUrl.toString());\n+        } else {\n+            this.container.textContent = \"No source URL!\";\n+        }\n     }\n \n     async loadAndRender(url) {\n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Error: ${response.status} ${response.statusText}`);\n+        try {\n+            const response = await fetch(url);\n+            if (!response.ok) {\n+                throw new Error(`Error: ${response.status} ${response.statusText}`);\n+            }\n+            const html = await response.text();\n+            if (url.endsWith(\".md\")) {\n+                const markdownElement = document.createElement('div');\n+                markdownElement.innerHTML = html;\n+                this.outerHTML = html;\n+            } else {\n+                this.container.innerHTML = html;\n+            }\n+        } catch (error) {\n+            this.container.textContent = `Error: ${error.message}`;\n         }\n-        const html = await response.text();\n-        if(url.endsWith(\".md\")){\n-          const parent = this\n-          const markdownElement = document.createElement('div')\n-          markdownElement.innerHTML = html\n-          this.outerHTML = html\n-        }else{\n-          this.container.innerHTML = html;\n-        }\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n-      }\n     }\n-  }\n-  customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\n+}\n+\n+customElements.define('html-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/markdown-frame.js b/src/snek/static/markdown-frame.js\nindex e2b7a77..6450ebb 100644\n--- a/src/snek/static/markdown-frame.js\n+++ b/src/snek/static/markdown-frame.js\n@@ -1,39 +1,48 @@\n \n \n+\n+\n \n class HTMLFrame extends HTMLElement {\n-    constructor() {\n-      super();\n-      this.attachShadow({ mode: 'open' });\n-      this.container = document.createElement('div');\n-      this.shadowRoot.appendChild(this.container);\n-    }\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: 'open' });\n+    this.container = document.createElement('div');\n+    this.shadowRoot.appendChild(this.container);\n+  }\n \n-    connectedCallback() {\n-        this.container.classList.add(\"html_frame\")\n-      const url = this.getAttribute('url');\n-      if (url) {\n-        const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\")\n-        if(!url.startsWith(\"/\"))\n-            fullUrl.searchParams.set('url', url)   \n-        this.loadAndRender(fullUrl.toString());\n-      } else {\n-        this.container.textContent = \"No source URL!\";\n-      }\n+  connectedCallback() {\n+    this.container.classList.add('html_frame');\n+    const url = this.getAttribute('url');\n+    if (url) {\n+      const fullUrl = url.startsWith('/')\n+        ? window.location.origin + url\n+        : new URL(window.location.origin + '/http-get');\n+      if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);\n+      this.loadAndRender(fullUrl.toString());\n+    } else {\n+      this.container.textContent = 'No source URL!';\n     }\n+  }\n \n-    async loadAndRender(url) {\n-      try {\n-        const response = await fetch(url);\n-        if (!response.ok) {\n-          throw new Error(`Error: ${response.status} ${response.statusText}`);\n-        }\n-        const html = await response.text();\n-        this.container.innerHTML = html;\n-        \n-    } catch (error) {\n-        this.container.textContent = `Error: ${error.message}`;\n+  async loadAndRender(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Error: ${response.status} ${response.statusText}`);\n       }\n+      const html = await response.text();\n+      this.container.innerHTML = html;\n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n     }\n   }\n-  customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\n+}\n+\n+customElements.define('markdown-frame', HTMLFrame);\n\\ No newline at end of file\ndiff --git a/src/snek/static/media-upload.js b/src/snek/static/media-upload.js\nindex e190aed..e73d098 100644\n--- a/src/snek/static/media-upload.js\n+++ b/src/snek/static/media-upload.js\n@@ -1,20 +1,34 @@\n+\n+\n+\n+\n+\n+\n class TileGridElement extends HTMLElement {\n-                \n     constructor() {\n         super();\n-        this.attachShadow({mode: 'open'});\n+        this.attachShadow({ mode: 'open' });\n         this.gridId = this.getAttribute('grid');\n         this.component = document.createElement('div');\n-        this.shadowRoot.appendChild(this.component)\n+        this.shadowRoot.appendChild(this.component);\n     }\n-  \n \n     connectedCallback() {\n         console.log('connected');\n         this.styleElement = document.createElement('style');\n-        this.styleElement.innerText = `\n+        this.styleElement.textContent = `\n             .grid {\n-            padding: 10px;\n+                padding: 10px;\n                 display: flex;\n                 flex-wrap: wrap;\n                 gap: 10px;\n@@ -32,13 +46,13 @@ class TileGridElement extends HTMLElement {\n             .grid .tile:hover {\n                 transform: scale(1.1);\n             }\n-\n         `;\n         this.component.appendChild(this.styleElement);\n         this.container = document.createElement('div');\n         this.container.classList.add('gallery');\n         this.component.appendChild(this.container);\n     }\n+\n     addImage(src) {\n         const item = document.createElement('img');\n         item.src = src;\n@@ -47,38 +61,39 @@ class TileGridElement extends HTMLElement {\n         item.style.height = '100px';\n         this.container.appendChild(item);\n     }\n+\n     addImages(srcs) {\n         srcs.forEach(src => this.addImage(src));\n     }\n+\n     addElement(element) {\n-        element.cclassList.add('tile');\n+        element.classList.add('tile');\n         this.container.appendChild(element);\n     }\n-\n }\n \n class UploadButton extends HTMLElement {\n     constructor() {\n         super();\n-        this.attachShadow({mode: 'open'});\n+        this.attachShadow({ mode: 'open' });\n         this.component = document.createElement('div');\n-    \n-        this.shadowRoot.appendChild(this.component)\n-        window.u = this\n+        this.shadowRoot.appendChild(this.component);\n+        window.u = this;\n     }\n-    get gridSelector(){\n+\n+    get gridSelector() {\n         return this.getAttribute('grid');\n     }\n-    grid = null\n+    grid = null;\n \n     addImages(urls) {\n         this.grid.addImages(urls);\n     }\n-    connectedCallback()\n-    {\n+\n+    connectedCallback() {\n         console.log('connected');\n         this.styleElement = document.createElement('style');\n-        this.styleElement.innerHTML = `\n+        this.styleElement.textContent = `\n         .upload-button {\n             display: flex;\n             flex-direction: column;\n@@ -112,7 +127,6 @@ class UploadButton extends HTMLElement {\n             const files = e.target.files;\n             const urls = [];\n             for (let i = 0; i < files.length; i++) {\n-                const file = files[i];\n                 const reader = new FileReader();\n                 reader.onload = (e) => {\n                     urls.push(e.target.result);\n@@ -120,7 +134,7 @@ class UploadButton extends HTMLElement {\n                         this.addImages(urls);\n                     }\n                 };\n-                reader.readAsDataURL(file);\n+                reader.readAsDataURL(files[i]);\n             }\n          });\n         const label = document.createElement('label');\n@@ -130,38 +144,32 @@ class UploadButton extends HTMLElement {\n     }\n }\n \n-customElements.define('upload-button', UploadButton);  \n-\n+customElements.define('upload-button', UploadButton);\n customElements.define('tile-grid', TileGridElement);\n \n class MeniaUploadElement extends HTMLElement {\n-\n     constructor(){\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.component = document.createElement(\"div\")\n-        alert('aaaa')\n-        this.shadowRoot.appendChild(this.component)\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.component = document.createElement(\"div\");\n+        alert('aaaa');\n+        this.shadowRoot.appendChild(this.component);\n     }\n+\n     connectedCallback() {\n-        \n-        this.container = document.createElement(\"div\")\n-        this.component.style.height = '100%'\n-        this.component.style.backgroundColor ='blue';\n-        this.shadowRoot.appendChild(this.container)\n-\n-        this.tileElement = document.createElement(\"tile-grid\")\n-        this.tileElement.style.backgroundColor = 'red'\n-        this.tileElement.style.height = '100%'\n-        this.component.appendChild(this.tileElement)\n-        \n-        this.uploadButton = document.createElement('upload-button')\n-        this.component.appendChild(this.uploadButton)\n-        \n-    }\n+        this.container = document.createElement(\"div\");\n+        this.component.style.height = '100%';\n+        this.component.style.backgroundColor = 'blue';\n+        this.shadowRoot.appendChild(this.container);\n \n+        this.tileElement = document.createElement(\"tile-grid\");\n+        this.tileElement.style.backgroundColor = 'red';\n+        this.tileElement.style.height = '100%';\n+        this.component.appendChild(this.tileElement);\n+\n+        this.uploadButton = document.createElement('upload-button');\n+        this.component.appendChild(this.uploadButton);\n+    }\n }\n \n-customElements.define('menia-upload', MeniaUploadElement)\n\\ No newline at end of file\n+customElements.define('menia-upload', MeniaUploadElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list-manager.js b/src/snek/static/message-list-manager.js\nindex 69a3f87..a7a8a78 100644\n--- a/src/snek/static/message-list-manager.js\n+++ b/src/snek/static/message-list-manager.js\n@@ -1,23 +1,44 @@\n+\n+\n+\n \n \n class MessageListManagerElement extends HTMLElement {\n     constructor() {\n-        super()\n-        this.attachShadow({mode:'open'})\n-        this.container = document.createElement(\"div\")\n-        this.shadowRoot.appendChild(this.container)\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+        this.container = document.createElement(\"div\");\n+        this.shadowRoot.appendChild(this.container);\n     }\n \n     async connectedCallback() {\n-        let channels = await app.rpc.getChannels()\n-        const me = this \n-        channels.forEach(channel=>{\n-            const messageList = document.createElement(\"message-list\")\n-            messageList.setAttribute(\"channel\",channel.uid)\n-            me.container.appendChild(messageList)\n-        })\n+        const channels = await app.rpc.getChannels();\n+        channels.forEach(channel => {\n+            const messageList = document.createElement(\"message-list\");\n+            messageList.setAttribute(\"channel\", channel.uid);\n+            this.container.appendChild(messageList);\n+        });\n     }\n-\n }\n \n-customElements.define(\"message-list-manager\",MessageListManagerElement)\n\\ No newline at end of file\n+customElements.define(\"message-list-manager\", MessageListManagerElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 1272281..17f3066 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -1,115 +1,113 @@\n \n \n-class MessageListElement extends HTMLElement {\n+\n \n+class MessageListElement extends HTMLElement {\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n-    messages = []\n-    room = null\n-    url = null\n-    container = null\n-    messageEventSchedule = null\n-    observer = null\n+\n+    messages = [];\n+    room = null;\n+    url = null;\n+    container = null;\n+    messageEventSchedule = null;\n+    observer = null;\n+\n     constructor() {\n-        super()\n+        super();\n         this.attachShadow({ mode: 'open' });\n-        this.component = document.createElement('div')\n-        this.shadowRoot.appendChild(this.component)\n+        this.component = document.createElement('div');\n+        this.shadowRoot.appendChild(this.component);\n     }\n+\n     linkifyText(text) {\n         const urlRegex = /https?:\\/\\/[^\\s]+/g;\n-\n-        return text.replace(urlRegex, (url) => {\n-            return `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`;\n-        });\n-\n+        return text.replace(urlRegex, (url) => `<a href=\"${url}\" target=\"_blank\" rel=\"noopener noreferrer\">${url}</a>`);\n     }\n-    timeAgo(date1, date2) {\n-        const diffMs = Math.abs(date2 - date1); \n \n+    timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1);\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n         const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n         const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n         const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+\n         if (days) {\n-            if (days > 1)\n-                return `${days} days ago`\n-            else\n-                return `${days} day ago`\n+            return `${days} ${days > 1 ? 'days' : 'day'} ago`;\n         }\n         if (hours) {\n-            if (hours > 1)\n-                return `${hours} hours ago`\n-            else\n-                return `${hours} hour ago`\n+            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;\n         }\n-        if (minutes)\n-            if (minutes > 1)\n-                return `${minutes} minutes ago`\n-            else\n-                return `${minutes} minute ago`\n-\n-        return `just now`\n+        if (minutes) {\n+            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;\n+        }\n+        return 'just now';\n     }\n+\n     timeDescription(isoDate) {\n-        const date = new Date(isoDate)\n+        const date = new Date(isoDate);\n         const hours = String(date.getHours()).padStart(2, \"0\");\n         const minutes = String(date.getMinutes()).padStart(2, \"0\");\n-        let timeStr = `${hours}:${minutes}`\n-        timeStr += \", \" + this.timeAgo(new Date(isoDate), Date.now())\n-        return timeStr\n+        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n+        return timeStr;\n     }\n+\n     createElement(message) {\n-        const element = document.createElement(\"div\")\n-        element.dataset.uid = message.uid\n-        element.dataset.color = message.color\n-        element.dataset.channel_uid = message.channel_uid\n-        element.dataset.user_nick = message.user_nick\n-        element.dataset.created_at = message.created_at\n-        element.dataset.user_uid = message.user_uid\n-        element.dataset.message = message.message\n-\n-        element.classList.add(\"message\")\n-        if (!this.messages.length) {\n-            element.classList.add(\"switch-user\")\n-        } else if (this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n-            element.classList.add(\"switch-user\")\n+        const element = document.createElement(\"div\");\n+        element.dataset.uid = message.uid;\n+        element.dataset.color = message.color;\n+        element.dataset.channel_uid = message.channel_uid;\n+        element.dataset.user_nick = message.user_nick;\n+        element.dataset.created_at = message.created_at;\n+        element.dataset.user_uid = message.user_uid;\n+        element.dataset.message = message.message;\n+\n+        element.classList.add(\"message\");\n+        if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {\n+            element.classList.add(\"switch-user\");\n         }\n-        const avatar = document.createElement(\"div\")\n-        avatar.classList.add(\"avatar\")\n-        avatar.style.backgroundColor = message.color\n-        avatar.style.color = \"black\"\n-        avatar.innerText = message.user_nick[0]\n-        const messageContent = document.createElement(\"div\")\n-        messageContent.classList.add(\"message-content\")\n-        const author = document.createElement(\"div\")\n-        author.classList.add(\"author\")\n-        author.style.color = message.color\n-        author.textContent = message.user_nick\n-        const text = document.createElement(\"div\")\n-        text.classList.add(\"text\")\n-        if (message.html)\n-            text.innerHTML = message.html\n-        const time = document.createElement(\"div\")\n-        time.classList.add(\"time\")\n-        time.dataset.created_at = message.created_at\n-        messageContent.appendChild(author)\n-        time.textContent = this.timeDescription(message.created_at)\n-        messageContent.appendChild(text)\n-        messageContent.appendChild(time)\n-        element.appendChild(avatar)\n-        element.appendChild(messageContent)\n-\n-\n-\n-\n-        message.element = element\n-\n-        return element\n+\n+        const avatar = document.createElement(\"div\");\n+        avatar.classList.add(\"avatar\");\n+        avatar.style.backgroundColor = message.color;\n+        avatar.style.color = \"black\";\n+        avatar.innerText = message.user_nick[0];\n+\n+        const messageContent = document.createElement(\"div\");\n+        messageContent.classList.add(\"message-content\");\n+\n+        const author = document.createElement(\"div\");\n+        author.classList.add(\"author\");\n+        author.style.color = message.color;\n+        author.textContent = message.user_nick;\n+\n+        const text = document.createElement(\"div\");\n+        text.classList.add(\"text\");\n+        if (message.html) text.innerHTML = message.html;\n+\n+        const time = document.createElement(\"div\");\n+        time.classList.add(\"time\");\n+        time.dataset.created_at = message.created_at;\n+        time.textContent = this.timeDescription(message.created_at);\n+\n+        messageContent.appendChild(author);\n+        messageContent.appendChild(text);\n+        messageContent.appendChild(time);\n+\n+        element.appendChild(avatar);\n+        element.appendChild(messageContent);\n+\n+        message.element = element;\n+\n+        return element;\n     }\n-    addMessage(message) {\n \n+    addMessage(message) {\n         const obj = new models.Message(\n             message.uid,\n             message.channel_uid,\n@@ -120,52 +118,52 @@ class MessageListElement extends HTMLElement {\n             message.html,\n             message.created_at,\n             message.updated_at\n-        )\n-        const element = this.createElement(obj)\n-        this.messages.push(obj)\n-        this.container.appendChild(element)\n-        const me = this\n+        );\n \n-        this.messageEventSchedule.delay(() => {\n-            me.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }))\n-\n-        })\n+        const element = this.createElement(obj);\n+        this.messages.push(obj);\n+        this.container.appendChild(element);\n \n+        this.messageEventSchedule.delay(() => {\n+            this.dispatchEvent(new CustomEvent(\"message\", { detail: obj, bubbles: true }));\n+        });\n \n-        return obj\n+        return obj;\n     }\n+\n     scrollBottom() {\n         this.container.scrollTop = this.container.scrollHeight;\n     }\n+\n     connectedCallback() {\n-        const link = document.createElement('link')\n-        link.rel = 'stylesheet'\n-        link.href = '/base.css'\n-        this.component.appendChild(link)\n-        this.component.classList.add(\"chat-messages\")\n-        this.container = document.createElement('div')\n-        this.component.appendChild(this.container)\n-        this.messageEventSchedule = new Schedule(500)\n-        this.messages = []\n-        this.channel_uid = this.getAttribute(\"channel\")\n-        const me = this\n+        const link = document.createElement('link');\n+        link.rel = 'stylesheet';\n+        link.href = '/base.css';\n+        this.component.appendChild(link);\n+        this.component.classList.add(\"chat-messages\");\n+\n+        this.container = document.createElement('div');\n+        this.component.appendChild(this.container);\n+\n+        this.messageEventSchedule = new Schedule(500);\n+        this.messages = [];\n+        this.channel_uid = this.getAttribute(\"channel\");\n+\n         app.addEventListener(this.channel_uid, (data) => {\n-            me.addMessage(data)\n-        })\n-        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }))\n+            this.addMessage(data);\n+        });\n \n-        this.timeUpdateInterval = setInterval(() => {\n-            me.messages.forEach((message) => {\n-                const newText = me.timeDescription(message.created_at)\n+        this.dispatchEvent(new CustomEvent(\"rendered\", { detail: this, bubbles: true }));\n \n+        this.timeUpdateInterval = setInterval(() => {\n+            this.messages.forEach((message) => {\n+                const newText = this.timeDescription(message.created_at);\n                 if (newText != message.element.innerText) {\n-                    message.element.querySelector(\".time\").innerText = newText\n+                    message.element.querySelector(\".time\").innerText = newText;\n                 }\n-            })\n-        }, 30000)\n-\n+            });\n+        }, 30000);\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n+customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\ndiff --git a/src/snek/static/models.js b/src/snek/static/models.js\nindex 1c05b42..67daa57 100644\n--- a/src/snek/static/models.js\n+++ b/src/snek/static/models.js\n@@ -1,26 +1,26 @@\n+\n+\n+\n+\n class MessageModel {\n-    message = null \n-    html = null\n-    user_uid = null \n-    channel_uid = null \n-    created_at = null \n-    updated_at = null  \n-    element = null \n-    color = null\n-    constructor(uid, channel_uid,user_uid,user_nick, color,message,html,created_at, updated_at){\n-        this.uid = uid \n-        this.message = message \n-        this.html = html \n-        this.user_uid = user_uid \n+    constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {\n+        this.uid = uid\n+        this.message = message\n+        this.html = html\n+        this.user_uid = user_uid\n         this.user_nick = user_nick\n         this.color = color\n-        this.channel_uid = channel_uid  \n+        this.channel_uid = channel_uid\n         this.created_at = created_at\n         this.updated_at = updated_at\n+        this.element = null\n     }       \n }\n \n const models = {\n     Message: MessageModel\n-\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 3ed8069..36ae803 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -1,47 +1,54 @@\n \n+\n+\n \n class Schedule {\n+  constructor(msDelay = 100) {\n+    this.msDelay = msDelay;\n+    this._once = false;\n+    this.timeOutCount = 0;\n+    this.timeOut = null;\n+    this.interval = null;\n+  }\n \n-    constructor(msDelay) {\n-        if(!msDelay){\n-            msDelay = 100\n-        }\n-        this.msDelay = msDelay\n-        this._once = false\n-        this.timeOutCount = 0;\n-        this.timeOut = null \n-        this.interval = null \n-    }\n-    cancelRepeat() {\n-        clearInterval(this.interval)\n-        this.interval = null \n-    }\n-    cancelDelay() {\n-        clearTimeout(this.timeOut)\n-        this.timeOut = null\n-    }\n-    repeat(func){\n-        if(this.interval){\n-            return false \n-        }\n-        this.interval = setInterval(()=>{\n-            func()\n-        }, this.msDelay)\n-    }\n-    delay(func) {\n-        this.timeOutCount++\n-        if(this.timeOut){\n-            this.cancelDelay()\n-        }\n-        const me = this \n-        this.timeOut = setTimeout(()=>{\n-            func(me.timeOutCount)\n-            clearTimeout(me.timeOut)\n-            me.timeOut = null\n-            \n-            me.cancelDelay()\n-            me.timeOutCount = 0\n-        }, this.msDelay)\n+  cancelRepeat() {\n+    clearInterval(this.interval);\n+    this.interval = null;\n+  }\n+\n+  cancelDelay() {\n+    clearTimeout(this.timeOut);\n+    this.timeOut = null;\n+  }\n+\n+  repeat(func) {\n+    if (this.interval) {\n+      return false;\n     }\n+    this.interval = setInterval(() => {\n+      func();\n+    }, this.msDelay);\n+  }\n \n+  delay(func) {\n+    this.timeOutCount++;\n+    if (this.timeOut) {\n+      this.cancelDelay();\n+    }\n+    const me = this;\n+    this.timeOut = setTimeout(() => {\n+      func(me.timeOutCount);\n+      clearTimeout(me.timeOut);\n+      me.timeOut = null;\n+      me.cancelDelay();\n+      me.timeOutCount = 0;\n+    }, this.msDelay);\n+  }\n }\n\\ No newline at end of file\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nnew file mode 100644\nindex 0000000..c6cfaa2\n--- /dev/null\n+++ b/src/snek/static/upload-button.js\n@@ -0,0 +1,121 @@\n+\n+\n+\n+\n+class UploadButtonElement extends HTMLElement {\n+    constructor() {\n+        super();\n+        this.attachShadow({ mode: 'open' });\n+    }\n+\n+    async uploadFiles() {\n+        const fileInput = this.container.querySelector('.file-input');\n+        const uploadButton = this.container.querySelector('.upload-button');\n+\n+        if (!fileInput.files.length) {\n+            return;\n+        }\n+\n+        const files = fileInput.files;\n+        const formData = new FormData();\n+        for (let i = 0; i < files.length; i++) {\n+            formData.append('files[]', files[i]);\n+        }\n+\n+        const request = new XMLHttpRequest();\n+        request.open('POST', '/upload', true);\n+\n+        request.upload.onprogress = function (event) {\n+            if (event.lengthComputable) {\n+                const percentComplete = (event.loaded / event.total) * 100;\n+                uploadButton.innerText = `${Math.round(percentComplete)}%`;\n+            }\n+        };\n+\n+        request.onload = function () {\n+            if (request.status === 200) {\n+                progressBar.style.width = '0%';\n+                uploadButton.innerHTML = '\ud83d\udce4';\n+            } else {\n+                alert('Upload failed');\n+            }\n+        };\n+\n+        request.onerror = function () {\n+            alert('Error while uploading.');\n+        };\n+\n+        request.send(formData);\n+    }\n+\n+    connectedCallback() {\n+        this.styleElement = document.createElement('style');\n+        this.styleElement.innerHTML = `\n+            body {\n+                font-family: Arial, sans-serif;\n+                display: flex;\n+                justify-content: center;\n+                align-items: center;\n+                height: 100vh;\n+            }\n+            .upload-container {\n+                position: relative;\n+            }\n+            .upload-button {\n+                display: flex;\n+                align-items: center;\n+                justify-content: center;\n+                padding: 10px 20px;\n+                color: white;\n+                border: none;\n+                border-radius: 5px;\n+                cursor: pointer;\n+                font-size: 16px;\n+                position: relative;\n+                overflow: hidden;\n+            }\n+            .upload-button i {\n+                margin-right: 8px;\n+            }\n+            .progress {\n+                position: absolute;\n+                left: 0;\n+                top: 0;\n+                height: 100%;\n+                background: rgba(255, 255, 255, 0.4);\n+                width: 0%;\n+            }\n+            .hidden-input {\n+                display: none;\n+            }\n+        `;\n+        this.shadowRoot.appendChild(this.styleElement);\n+\n+        this.container = document.createElement('div');\n+        this.container.innerHTML = `\n+            <div class=\"upload-container\">\n+                <button class=\"upload-button\">\n+                   \ud83d\udce4\n+                </button>\n+                <input class=\"hidden-input file-input\" type=\"file\" multiple />\n+            </div>\n+        `;\n+        this.shadowRoot.appendChild(this.container);\n+\n+        this.uploadButton = this.container.querySelector('.upload-button');\n+        this.fileInput = this.container.querySelector('.hidden-input');\n+        this.uploadButton.addEventListener('click', () => {\n+            this.fileInput.click();\n+        });\n+        this.fileInput.addEventListener('change', () => {\n+            this.uploadFiles();\n+        });\n+    }\n+}\n+\n+customElements.define('upload-button', UploadButtonElement);\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 532f75f..0aaffc9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,7 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n-  <script src=\"/media-upload.js\"></script>\n+  <script src=\"/upload-button.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>"}
{"repo": ".", "date": "2025-02-03", "line": "fix: Removed unnecessary white-space and improved text wrapping", "commit": "f395d1617394045cac7c41af0cd5ce9d6ef55ed8", "diff": "commit f395d1617394045cac7c41af0cd5ce9d6ef55ed8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 20:46:41 2025 +0100\n\n    Useless button + wrapping\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 2393a62..5db5aa9 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -180,7 +180,6 @@ message-list {\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n-   white-space: pre-wrap;\n   word-break: break-word;\n   overflow-wrap: break-word;\n \n@@ -240,7 +239,6 @@ message-list {\n   max-width: 100%;\n   word-wrap: break-word;\n   overflow-wrap: break-word;\n-  white-space: pre-wrap; \n   hyphens: auto;\n     img {\n       max-width: 90%;"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed setup.cfg and updated dependencies and code\n\nThis commit removes the `setup.cfg` file and adjusts the project configuration accordingly. It also updates dependencies and refactors some code in `Dockerfile`, `DockerfileDrive`, `pyproject.toml`, and `src/snek/static/app.js` to improve reliability and maintainability.\n", "commit": "084f8dba2075aec93d9d88fd7cdd7f67fc63a212", "diff": "commit 084f8dba2075aec93d9d88fd7cdd7f67fc63a212\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 3 21:15:18 2025 +0100\n\n    Heavy repair.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex 9af8e87..ffdc3d7 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -30,7 +30,6 @@ RUN apk add --no-cache \\\n   && apk del .build-deps\n COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n-COPY setup.cfg setup.cfg \n COPY pyproject.toml pyproject.toml \n COPY src src\n RUN pip install --upgrade pip\ndiff --git a/DockerfileDrive b/DockerfileDrive\nindex 28f183a..0a03850 100644\n--- a/DockerfileDrive\n+++ b/DockerfileDrive\n@@ -4,7 +4,6 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n \n \n-COPY setup.cfg setup.cfg \n COPY pyproject.toml pyproject.toml \n COPY src src\n COpy ssh_host_key ssh_host_key\ndiff --git a/pyproject.toml b/pyproject.toml\nindex ed36d70..5a147ae 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -27,6 +27,7 @@ dependencies = [\n     \"requests\",\n     \"asyncssh\",\n     \"emoji\",\n-    \"pywebpush\"\n+    \"pywebpush\",\n+    \"aiofiles\"\n ]\n \ndiff --git a/setup.cfg b/setup.cfg\ndeleted file mode 100644\nindex 045fc92..0000000\n--- a/setup.cfg\n+++ /dev/null\n@@ -1,29 +0,0 @@\n-[metadata]\n-name = snek\n-version = 1.0.0\n-description = Snek chat server \n-author = retoor\n-author_email = retoor@molodetz.nl\n-license = MIT\n-long_description = file: README.md\n-long_description_content_type = text/markdown\n-\n-[options]\n-packages = find:\n-package_dir =\n-    = src\n-python_requires = >=3.7\n-install_requires =\n-    beautifulsoup4\n-    gunicorn\n-    imgkit\n-    wkhtmltopdf\n-    shed\n-\n-[options.packages.find]\n-where = src\n-\n-[options.entry_points]\n-console_scripts =\n-    snek.serve = snek.server:cli\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex ada9654..06fef95 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -166,9 +166,10 @@ class Socket extends EventHandler {\n         if (this.ensureTimer) {\n             return this.connect();\n         }\n+        const me = this;\n         this.ensureTimer = setInterval(() => {\n-            if (this.isConnecting) this.isConnecting = false;\n-            this.connect();\n+            if (me.isConnecting) me.isConnecting = false;\n+            me.connect();\n         }, 5000);\n         return this.connect();\n     }\n@@ -178,32 +179,34 @@ class Socket extends EventHandler {\n     }\n \n     connect() {\n+        \n+        const me = this \n         if (this.isConnected || this.isConnecting) {\n             return new Promise((resolve) => {\n-                this.connectPromises.push(resolve);\n-                if (!this.isConnected) resolve(this);\n+                me.connectPromises.push(resolve);\n+                if (!me.isConnecting) resolve(me);\n             });\n         }\n         this.isConnecting = true;\n         return new Promise((resolve) => {\n-            this.connectPromises.push(resolve);\n+            me.connectPromises.push(resolve);\n             console.debug(\"Connecting..\");\n \n-            const ws = new WebSocket(this.url);\n+            const ws = new WebSocket(me.url);\n             ws.onopen = () => {\n-                this.ws = ws;\n-                this.isConnected = true;\n-                this.isConnecting = false;\n+                me.ws = ws;\n+                me.isConnected = true;\n+                me.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    this.onData(JSON.parse(event.data));\n+                    me.onData(JSON.parse(event.data));\n                 };\n                 ws.onclose = () => {\n-                    this.onClose();\n+                    me.onClose();\n                 };\n                 ws.onerror = () => {\n-                    this.onClose();\n+                    me.onClose();\n                 };\n-                this.connectPromises.forEach(resolver => resolver(this));\n+                me.connectPromises.forEach(resolver => resolver(me));\n             };\n         });\n     }\n@@ -233,9 +236,10 @@ class Socket extends EventHandler {\n             method,\n             args,\n         };\n+        const me = this \n         return new Promise((resolve) => {\n-            this.addEventListener(call.callId, data => resolve(data));\n-            this.sendJson(call);\n+            me.addEventListener(call.callId, data => resolve(data));\n+            me.sendJson(call);\n         });\n     }\n \n@@ -281,12 +285,13 @@ class App extends EventHandler {\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n+        const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            this.emit(data.channel_uid, data);\n+            me.emit(data.channel_uid, data);\n         });\n \n         this.rpc.getUser(null).then(user => {\n-            this.user = user;\n+            me.user = user;\n         });\n     }\n \n@@ -296,14 +301,15 @@ class App extends EventHandler {\n \n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n+        const me = this; \n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n-                    this.rpc.sendMessage(channel.uid, `${message} ${i}`);\n+                    me.rpc.sendMessage(channel.uid, `${message} ${i}`);\n                 });\n             }));\n         }\n     }\n }\n \n-const app = new App();\n\\ No newline at end of file\n+const app = new App();"}
{"repo": ".", "date": "2025-02-04", "line": "feat: Added drive service and upload functionality", "commit": "6f9adfe67fd551dd99746c40bb55706a7ffcef3b", "diff": "commit 6f9adfe67fd551dd99746c40bb55706a7ffcef3b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 4 23:38:13 2025 +0100\n\n    Drive service.\n\ndiff --git a/.gitignore b/.gitignore\nindex c1f3aef..ce8bd16 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -8,6 +8,8 @@ snek.d*\n *.zip\n *.db*\n cache\n+drive\n+\n __pycache__/\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 137abf0..8cfed8f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -26,8 +26,8 @@ from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n+from snek.view.upload import UploadView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -81,6 +81,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive.bin\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 1841346..e4c67b0 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -5,6 +5,8 @@ from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n+from snek.mapper.drive import DriveMapper \n+from snek.mapper.drive_item import DriveItemMapper\n from snek.system.object import Object\n \n \n@@ -17,6 +19,8 @@ def get_mappers(app=None):\n             \"channel\": ChannelMapper(app=app),\n             \"channel_message\": ChannelMessageMapper(app=app),\n             \"notification\": NotificationMapper(app=app),\n+            \"drive_item\": DriveItemMapper(app=app),\n+            \"drive\": DriveMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nnew file mode 100644\nindex 0000000..970788a\n--- /dev/null\n+++ b/src/snek/mapper/drive.py\n@@ -0,0 +1,7 @@\n+from snek.model.drive import DriveModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class DriveMapper(BaseMapper):\n+    table_name = 'drive'\n+    model_class = DriveModel \ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nnew file mode 100644\nindex 0000000..c35afe1\n--- /dev/null\n+++ b/src/snek/mapper/drive_item.py\n@@ -0,0 +1,7 @@\n+from snek.system.mapper import BaseMapper\n+from snek.model.drive_item import DriveItemModel \n+\n+class DriveItemMapper(BaseMapper):\n+    \n+    model_class = DriveItemModel\n+    table_name = 'drive_item'\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nnew file mode 100644\nindex 0000000..a310bbd\n--- /dev/null\n+++ b/src/snek/model/drive.py\n@@ -0,0 +1,7 @@\n+from snek.system.model import BaseModel,ModelField\n+\n+\n+class DriveModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    \ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nnew file mode 100644\nindex 0000000..74b8deb\n--- /dev/null\n+++ b/src/snek/model/drive_item.py\n@@ -0,0 +1,9 @@\n+from snek.system.model import BaseModel,ModelField \n+\n+\n+class DriveItemModel(BaseModel):\n+    drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n+    name = ModelField(name=\"name\", required=True,kind=str)\n+    path = ModelField(name=\"path\", required=True,kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True,kind=str) \n+    file_size = ModelField(name=\"file_size\", required=True,kind=int)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 97fbaae..e521c7b 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -8,6 +8,8 @@ from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n+from snek.service.drive import DriveService\n+from snek.service.drive_item import DriveItemService\n from snek.system.object import Object\n \n \n@@ -23,6 +25,8 @@ def get_services(app):\n             \"socket\": SocketService(app=app),\n             \"notification\": NotificationService(app=app),\n             \"util\": UtilService(app=app),\n+            \"drive\": DriveService(app=app),\n+            \"drive_item\": DriveItemService(app=app)\n         }\n     )\n \ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 8765d53..dcc12d5 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -29,10 +29,7 @@ class ChannelMessageService(BaseService):\n             model[\"html\"] = template.render(**context)\n         except Exception as ex:\n             print(ex,flush=True)\n-        print(\"RENDER\",flush=True)\n-        print(\"RECORD\",context,flush=True)\n         \n-        print(\"AFTER RENDER\",flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nnew file mode 100644\nindex 0000000..9d409a8\n--- /dev/null\n+++ b/src/snek/service/drive.py\n@@ -0,0 +1,21 @@\n+from snek.system.service import BaseService\n+\n+\n+class DriveService(BaseService):\n+    \n+    mapper_name = \"drive\"\n+\n+    async def get_by_user(self, user_uid):\n+        drives = [] \n+        async for model in self.find(user_uid=user_uid):\n+            drives.append(model)\n+        return drives \n+\n+    async def get_or_create(self, user_uid):\n+        drives = await self.get_by_user(user_uid=user_uid)\n+        if len(drives) == 0:\n+            model = await self.new()\n+            model['user_uid'] = user_uid  \n+            await self.save(model)\n+            return model \n+        return drives[0]\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nnew file mode 100644\nindex 0000000..058f55e\n--- /dev/null\n+++ b/src/snek/service/drive_item.py\n@@ -0,0 +1,18 @@\n+from snek.system.service import BaseService \n+\n+\n+class DriveItemService(BaseService):\n+\n+    mapper_name = \"drive_item\"\n+\n+    async def create(self, drive_uid, name, path, type_,size):\n+        model = await self.new() \n+        model['drive_uid'] = drive_uid \n+        model['name'] = name \n+        model['path'] = str(path) \n+        model['file_type'] = type_ \n+        model['file_size'] = size\n+        if await self.save(model):\n+            return model \n+        errors = await model.errors\n+        raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 06fef95..a35f153 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -189,24 +189,24 @@ class Socket extends EventHandler {\n         }\n         this.isConnecting = true;\n         return new Promise((resolve) => {\n-            me.connectPromises.push(resolve);\n+            this.connectPromises.push(resolve);\n             console.debug(\"Connecting..\");\n \n-            const ws = new WebSocket(me.url);\n+            const ws = new WebSocket(this.url);\n             ws.onopen = () => {\n-                me.ws = ws;\n-                me.isConnected = true;\n-                me.isConnecting = false;\n+                this.ws = ws;\n+                this.isConnected = true;\n+                this.isConnecting = false;\n                 ws.onmessage = (event) => {\n-                    me.onData(JSON.parse(event.data));\n+                    this.onData(JSON.parse(event.data));\n                 };\n                 ws.onclose = () => {\n-                    me.onClose();\n+                    this.onClose();\n                 };\n                 ws.onerror = () => {\n-                    me.onClose();\n+                    this.onClose();\n                 };\n-                me.connectPromises.forEach(resolver => resolver(me));\n+                this.connectPromises.forEach(resolver => resolver(this));\n             };\n         });\n     }\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex 6a9353c..c1d767d 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -7,14 +7,23 @@\n \n class ChatInputElement extends HTMLElement {\n-  \n+  _chatWindow = null \n   constructor() {\n     super();\n     this.attachShadow({ mode: 'open' });\n     this.component = document.createElement('div');\n     this.shadowRoot.appendChild(this.component);\n   }\n+  set chatWindow(value){\n+    this._chatWindow = value \n \n+  }\n+  get chatWindow(){\n+    return this._chatWindow \n+  }\n+  get channelUid() {\n+    return this.chatWindow.channel.uid\n+  }\n   connectedCallback() {\n     const link = document.createElement('link');\n     link.rel = 'stylesheet';\n@@ -28,7 +37,8 @@ class ChatInputElement extends HTMLElement {\n       <upload-button></upload-button>\n     `;\n     this.textBox = this.container.querySelector('textarea');\n-\n+    this.uploadButton = this.container.querySelector('upload-button');\n+    this.uploadButton.chatInput = this \n     this.textBox.addEventListener('input', (e) => {\n       this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n       const message = e.target.value;\n@@ -56,4 +66,4 @@ class ChatInputElement extends HTMLElement {\n   }\n }\n \n-customElements.define('chat-input', ChatInputElement);\n\\ No newline at end of file\n+customElements.define('chat-input', ChatInputElement);\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 2c2973a..0b341dc 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -13,7 +13,7 @@\n \n class ChatWindowElement extends HTMLElement {\n     receivedHistory = false;\n-\n+    channel = null\n     constructor() {\n         super();\n         this.attachShadow({ mode: 'open' });\n@@ -47,6 +47,7 @@ class ChatWindowElement extends HTMLElement {\n \n         const channels = await app.rpc.getChannels();\n         const channel = channels[0];\n+        this.channel = channel;\n         chatTitle.innerText = channel.name;\n \n         const channelElement = document.createElement('message-list');\n@@ -54,6 +55,7 @@ class ChatWindowElement extends HTMLElement {\n         this.container.appendChild(channelElement);\n \n         const chatInput = document.createElement('chat-input');\n+        chatInput.chatWindow = this;\n         chatInput.addEventListener(\"submit\", (e) => {\n             app.rpc.sendMessage(channel.uid, e.detail);\n         });\n@@ -75,4 +77,4 @@ class ChatWindowElement extends HTMLElement {\n     }\n }\n \n-customElements.define('chat-window', ChatWindowElement);\n\\ No newline at end of file\n+customElements.define('chat-window', ChatWindowElement);\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex c6cfaa2..6e3052f 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -10,7 +10,7 @@ class UploadButtonElement extends HTMLElement {\n         super();\n         this.attachShadow({ mode: 'open' });\n     }\n-\n+    chatInput = null \n     async uploadFiles() {\n         const fileInput = this.container.querySelector('.file-input');\n         const uploadButton = this.container.querySelector('.upload-button');\n@@ -21,12 +21,13 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n+        formData.append('channel_uid', this.chatInput.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n \n         const request = new XMLHttpRequest();\n-        request.open('POST', '/upload', true);\n+        request.open('POST', '/drive.bin', true);\n \n         request.upload.onprogress = function (event) {\n             if (event.lengthComputable) {\n@@ -37,7 +38,6 @@ class UploadButtonElement extends HTMLElement {\n \n         request.onload = function () {\n             if (request.status === 200) {\n-                progressBar.style.width = '0%';\n                 uploadButton.innerHTML = '\ud83d\udce4';\n             } else {\n                 alert('Upload failed');\n@@ -50,7 +50,7 @@ class UploadButtonElement extends HTMLElement {\n \n         request.send(formData);\n     }\n-\n+    channelUid = null\n     connectedCallback() {\n         this.styleElement = document.createElement('style');\n         this.styleElement.innerHTML = `\n@@ -95,7 +95,6 @@ class UploadButtonElement extends HTMLElement {\n             }\n         `;\n         this.shadowRoot.appendChild(this.styleElement);\n-\n         this.container = document.createElement('div');\n         this.container.innerHTML = `\n             <div class=\"upload-container\">\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nnew file mode 100644\nindex 0000000..ff208e9\n--- /dev/null\n+++ b/src/snek/view/upload.py\n@@ -0,0 +1,56 @@\n+from snek.system.view import BaseView\n+import aiofiles \n+import pathlib\n+from aiohttp import web\n+import uuid \n+\n+UPLOAD_DIR = pathlib.Path(\"./drive\")\n+\n+class UploadView(BaseView):\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        drive_item = await self.services.drive_item.get(uid)\n+        \n+        print(await drive_item.to_json(),flush=True)\n+        return web.FileResponse(drive_item[\"path\"])       \n+\n+    async def post(self):\n+        reader = await self.request.multipart()\n+        files = []  \n+\n+        UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n+\n+        channel_uid = None \n+\n+        drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n+\n+        print(str(drive),flush=True)\n+\n+        while field := await reader.next():\n+\n+            if field.name == \"channel_uid\":\n+                channel_uid = await field.text()\n+                continue\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+            \n+            file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n+            files.append(file_path)\n+ \n+            async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+\n+            drive_item = await self.services.drive_item.create(drive[\"uid\"],filename,str(file_path.absolute()),file_path.stat().st_size,file_path.suffix)\n+\n+            await self.services.chat.send(self.request.session.get(\"uid\"),channel_uid,f\"![{filename}](/drive.bin/{drive_item['uid']})\")\n+            print(drive_item,flush=True)\n+\n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files],\"channel_uid\":channel_uid})\n+\n+\n+"}
{"repo": ".", "date": "2025-02-05", "line": "feat: Added :snek1: emoji support", "commit": "b6185a95f3fcbf539ec0ba767d4c0923092f8e82", "diff": "commit b6185a95f3fcbf539ec0ba767d4c0923092f8e82\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 5 19:11:11 2025 +0100\n\n    Added :snek1:\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 5a147ae..2d5b6d9 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -27,7 +27,7 @@ dependencies = [\n     \"requests\",\n     \"asyncssh\",\n     \"emoji\",\n-    \"pywebpush\",\n-    \"aiofiles\"\n+    \"aiofiles\",\n+    \"PyJWT\"\n ]\n \ndiff --git a/src/snek/static/emoji/snek1.gif b/src/snek/static/emoji/snek1.gif\nnew file mode 100644\nindex 0000000..9d34458\nBinary files /dev/null and b/src/snek/static/emoji/snek1.gif differ\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex ed153f0..89496a0 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -7,7 +7,7 @@ from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n-\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n \n def set_link_target_blank(text):\n     soup = BeautifulSoup(text, 'html.parser')\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex bcff7fb..c271db3 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@"}
{"repo": ".", "date": "2025-02-06", "line": "feat: Add push.js and update RPCView success handling", "commit": "203314b209030f297cd888685bb68721bc21c61b", "diff": "commit 203314b209030f297cd888685bb68721bc21c61b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 6 17:31:14 2025 +0100\n\n    Updated False if failed login.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0aaffc9..4d091cf 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -5,6 +5,7 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <script src=\"/push.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 531aa40..a144df2 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -127,13 +127,15 @@ class RPCView(BaseView):\n                 method = getattr(self,method_name.replace(\".\",\"_\"),None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n+                success = True \n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    success = False \n                     print(result,flush=True)\n-                await self._send_json({\"callId\":call_id,\"success\":True,\"data\":result})\n+                await self._send_json({\"callId\":call_id,\"success\":success,\"data\":result})\n             except Exception as ex:\n                 await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Correctly handle missing language specifier in code blocks", "commit": "386d9c3aaee80115241866ae72df9fad3ea3c714", "diff": "commit 386d9c3aaee80115241866ae72df9fad3ea3c714\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 17:49:29 2025 +0100\n\n    Fixed markdown.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 23d0656..198f589 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -28,9 +28,11 @@ class MarkdownRenderer(HTMLRenderer):\n         if not lang:\n             lang = info\n         if not lang:\n-            return f\"<div>{code}</div>\"\n+            lang = 'bash'\n         lexer = get_lexer_by_name(lang, stripall=True)\n+        if not lexer:\n+            return f\"<pre>{code}</pre>\"\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         return highlight(code, lexer, formatter)"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Handle empty code highlighting results", "commit": "d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e", "diff": "commit d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:02:10 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 198f589..1f01354 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -31,10 +31,11 @@ class MarkdownRenderer(HTMLRenderer):\n             lang = 'bash'\n         lexer = get_lexer_by_name(lang, stripall=True)\n-        if not lexer:\n-            return f\"<pre>{code}</pre>\"\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        return highlight(code, lexer, formatter)\n+        result = highlight(code, lexer, formatter)\n+        if not result:\n+            return f\"<pre>{code}</pre>\"\n+        return result \n \n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Escape code blocks in markdown renderer", "commit": "a301e2c5bfb8286f63a48c2860162780f95e820d", "diff": "commit a301e2c5bfb8286f63a48c2860162780f95e820d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:06:48 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 1f01354..97416a3 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-\n+from html.parser import escape\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n@@ -34,7 +34,7 @@ class MarkdownRenderer(HTMLRenderer):\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n         if not result:\n-            return f\"<pre>{code}</pre>\"\n+            return f\"<pre>{escape(code)}</pre>\"\n         return result \n \n     def render(self):"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Updated html escaping import", "commit": "cfa2af61b81b613bf2b8177b8acff1ea8b7c8576", "diff": "commit cfa2af61b81b613bf2b8177b8acff1ea8b7c8576\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:09:15 2025 +0100\n\n    Changes formatter.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 97416a3..f0b6e25 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-from html.parser import escape\n+from html import escape\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Add code highlighting with lexer selection", "commit": "51f1b1d86e4813c10e2750f0771c1bdcc1274bfb", "diff": "commit 51f1b1d86e4813c10e2750f0771c1bdcc1274bfb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 18:21:41 2025 +0100\n\n    Changed markdown.\n\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex f0b6e25..ca603d8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -24,17 +24,20 @@ class MarkdownRenderer(HTMLRenderer):\n     def _escape(self, str):\n \n+    def get_lexer(self, lang, default='bash'):\n+        try:\n+            return get_lexer_by_name(lang, stripall=True)\n+        except:\n+            return get_lexer_by_name(default, stripall=True)\n+    \n     def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n             lang = 'bash'\n-        lexer = get_lexer_by_name(lang, stripall=True)\n+        lexer = self.get_lexer(lang)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n-        if not result:\n-            return f\"<pre>{escape(code)}</pre>\"\n         return result \n \n     def render(self):"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Replaced navigation links with emojis", "commit": "9840c8eb03f969330583e9c3a7b28dbb5548f7d6", "diff": "commit 9840c8eb03f969330583e9c3a7b28dbb5548f7d6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 19:42:03 2025 +0100\n\n    Applied emoticons.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4d091cf..4202b88 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -24,10 +24,11 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n-      <a href=\"/web.html\">Home</a>\n-      <a href=\"/logout.html\">Logout</a>\n+      <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/web.html\">\ud83d\udc65</a>\n+      <a href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n   </header>\n   <main>"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Prevent memory leaks by closing and nulling websocket on disconnect", "commit": "7ca2bc5776213828a31c7fc237784a0a73c6f759", "diff": "commit 7ca2bc5776213828a31c7fc237784a0a73c6f759\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 7 19:45:05 2025 +0100\n\n    Removed double sockets.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a35f153..2f3e610 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -247,6 +247,8 @@ class Socket extends EventHandler {\n         console.info(\"Connection lost. Reconnecting.\");\n         this.isConnected = false;\n         this.isConnecting = false;\n+        this.ws.close();\n+        this.ws = null;\n         this.ensureConnection().then(() => {\n             console.info(\"Reconnected.\");\n         });"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Allow underscores in usernames and add user search functionality", "commit": "f291c0f2e4081fde4ed55d6cc25fdcbb1952af70", "diff": "commit f291c0f2e4081fde4ed55d6cc25fdcbb1952af70\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:07:04 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 1384b8f..9331fae 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -20,7 +20,7 @@ class RegisterForm(Form):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n         place_holder=\"Username\",\n         type=\"text\",\n     )\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex eb14ee7..1cad3ee 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -4,6 +4,15 @@ from snek.system.service import BaseService\n \n class UserService(BaseService):\n     mapper_name = \"user\"\n+    \n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):\n+            results.append(result)\n+        return results\n \n     async def validate_login(self, username, password):\n         model = await self.get(username=username)\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4202b88..0225965 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,65 +1,4 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Snek</title>\n-  <style>{{highlight_styles}}</style>\n-  <script src=\"/push.js\"></script>\n-  <script src=\"/upload-button.js\"></script>\n-  <script src=\"/html-frame.js\"></script>\n-  <script src=\"/schedule.js\"></script>\n-  <script src=\"/app.js\"></script>\n-  <script src=\"/models.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <script src=\"/message-list-manager.js\"></script>\n-  <script src=\"/chat-input.js\"></script>\n-  <script src=\"/chat-window.js\"></script>\n-  <link rel=\"stylesheet\" href=\"/base.css\">\n-  <link rel=\"manifest\" href=\"/manifest.json\" />\n-  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n-\n-</head>\n-<body>\n-  <header>\n-    <div class=\"logo\">Snek</div>\n-    <nav>\n-      <a href=\"/web.html\">\ud83c\udfe0</a>\n-      <a href=\"/web.html\">\ud83d\udc65</a>\n-      <a href=\"/logout.html\">\ud83d\udd12</a>\n-    </nav>\n-  </header>\n-  <main>\n-    <aside class=\"sidebar\">\n-      <h2>Chat Rooms</h2>\n-      <ul>\n-       \n-      </ul>\n-    </aside>\n+{% extends \"app.html\" %} \n+{% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\n-  </main>\n-  <script>\n-let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-           \n-            const button = document.getElementById(\"install-button\")\n-            button.addEventListener(\"click\", async ()=>{  \n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n-            })\n-            button.style.display = 'inline-block'\n-            \n-          });\n-        ;\n-    </script>\n-</body>\n-</html>\n+{% endblock %}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex a144df2..d4b2e59 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -46,6 +46,11 @@ class RPCView(BaseView):\n                 await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n        \n             return record \n+\n+        async def search_user(self, query): \n+            self._require_login()\n+            return [user['username'] for user in await self.services.user.search(query)]\n+\n         async def get_user(self, user_uid):\n             self._require_login()\n             if not user_uid:"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Initial app template with basic structure and links", "commit": "fcb05903f3f583ce8532d65ee7edf1ad8df91df4", "diff": "commit fcb05903f3f583ce8532d65ee7edf1ad8df91df4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:09:05 2025 +0100\n\n    Fix template.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nnew file mode 100644\nindex 0000000..2ff66eb\n--- /dev/null\n+++ b/src/snek/templates/app.html\n@@ -0,0 +1,68 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+  <title>Snek</title>\n+  <style>{{highlight_styles}}</style>\n+  <script src=\"/push.js\"></script>\n+  <script src=\"/upload-button.js\"></script>\n+  <script src=\"/html-frame.js\"></script>\n+  <script src=\"/schedule.js\"></script>\n+  <script src=\"/app.js\"></script>\n+  <script src=\"/models.js\"></script>\n+  <script src=\"/message-list.js\"></script>\n+  <script src=\"/message-list-manager.js\"></script>\n+  <script src=\"/chat-input.js\"></script>\n+  <script src=\"/chat-window.js\"></script>\n+  <link rel=\"stylesheet\" href=\"/base.css\">\n+  <link rel=\"manifest\" href=\"/manifest.json\" />\n+  <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n+</head>\n+<body>\n+  <header>\n+    <div class=\"logo\">Snek</div>\n+    <nav>\n+      <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/web.html\">\ud83d\udc65</a>\n+      <a href=\"/logout.html\">\ud83d\udd12</a>\n+    </nav>\n+  </header>\n+  <main>\n+    {% block sidebar %}\n+    <aside class=\"sidebar\">\n+      <h2>Chat Rooms</h2>\n+      <ul>\n+       \n+      </ul>\n+    </aside>\n+    {% endblock %}\n+    {% block main %}\n+    <chat-window class=\"chat-area\"></chat-window>\n+    {% endblock %}\n+  </main>\n+  <script>\n+let installPrompt = null \n+        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n+            event.preventDefault();\n+            installPrompt = event;\n+            alert(\"Jaaah\") \n+            const button = document.getElementById(\"install-button\")\n+            button.addEventListener(\"click\", async ()=>{  \n+            const result = await installPrompt.prompt()\n+            console.info(result.outcome)\n+            })\n+            button.style.display = 'inline-block'\n+            \n+          });\n+        ;\n+    </script>\n+</body>\n+</html>"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Relaxed username and password regex constraints", "commit": "8d0d709e18be0177b99f76f320eeb02b70bb41b0", "diff": "commit 8d0d709e18be0177b99f76f320eeb02b70bb41b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:14:37 2025 +0100\n\n    Fix template.\n\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex 2966053..ef13d67 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -23,14 +23,14 @@ class LoginForm(Form):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n         place_holder=\"Username\",\n         type=\"text\",\n     )\n     password = AuthField(\n         name=\"password\",\n         required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        min_length=1,\n         type=\"password\",\n         place_holder=\"Password\",\n     )\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex 9331fae..b105696 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -34,7 +34,7 @@ class RegisterForm(Form):\n     password = FormInputElement(\n         name=\"password\",\n         required=True,\n-        regex=r\"^[a-zA-Z0-9_.+-]{6,}\",\n+        min_length=1,\n         type=\"password\",\n         place_holder=\"Password\",\n     )\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 2910a6d..cac9aaf 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -15,7 +15,7 @@ class UserModel(BaseModel):\n         required=True,\n         min_length=2,\n         max_length=20,\n-        regex=r\"^[a-zA-Z0-9_]+$\",\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     color = ModelField(\n         name =\"color\",\n@@ -28,4 +28,4 @@ class UserModel(BaseModel):\n         required=False,\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n-    password = ModelField(name=\"password\", required=True, regex=r\"^[a-zA-Z0-9_.+-]{6,}\")\n+    password = ModelField(name=\"password\", required=True, min_length=1)"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Added search user functionality with HTML and API endpoint", "commit": "d7b943dc8c8f485c975730d6054e32e67db36c91", "diff": "commit d7b943dc8c8f485c975730d6054e32e67db36c91\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:31:03 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 8cfed8f..6d0d1e6 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -27,6 +27,7 @@ from snek.view.rpc import RPCView\n from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n+from snek.view.search_user import SearchUserView \n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -83,6 +84,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n         self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n+        self.router.add_view(\"/search-user.html\", SearchUserView)\n+        self.router.add_view(\"/search-user.json\", SearchUserView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/static/push.js b/src/snek/static/push.js\nnew file mode 100644\nindex 0000000..806a51e\n--- /dev/null\n+++ b/src/snek/static/push.js\n@@ -0,0 +1,30 @@\n+this.onpush = (event) => {\n+            console.log(event.data);\n+        };\n+\n+        navigator.serviceWorker\n+            .register(\"service-worker.js\")\n+            .then((serviceWorkerRegistration) => {\n+                serviceWorkerRegistration.pushManager.subscribe().then(\n+                    (pushSubscription) => {\n+                        const subscriptionObject = {\n+                            endpoint: pushSubscription.endpoint,\n+                            keys: {\n+                                p256dh: pushSubscription.getKey('p256dh'),\n+                                auth: pushSubscription.getKey('auth'),\n+                            },\n+                            encoding: PushManager.supportedContentEncodings,\n+                        };\n+                        console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);\n+                    },\n+                    (error) => {\n+                        console.error(error);\n+                    },\n+                );\n+            });\ndiff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js\nnew file mode 100644\nindex 0000000..dfdc493\n--- /dev/null\n+++ b/src/snek/static/service-worker.js\n@@ -0,0 +1,30 @@\n+self.addEventListener(\"install\", (event) => {\n+  console.log(\"Service worker installed\");\n+});\n+\n+self.addEventListener(\"push\",  (event) => {\n+  if (!(self.Notification && self.Notification.permission === \"granted\")) {\n+    return;\n+  }\n+  console.log(\"Received a push message\", event);\n+\n+  const data = event.data?.json() ?? {};\n+  const title = data.title || \"Something Has Happened\";\n+  const message =\n+    data.message || \"Here's something you might want to check out.\";\n+  const icon = \"images/new-notification.png\";\n+\n+  event.waitUntil(self.registration.showNotification(title, {\n+    body: message,\n+    tag: \"simple-push-demo-notification\",\n+    icon,\n+  }));\n+\n+});\n+\n+self.addEventListener(\"notificationclick\", (event) => {\n+    console.log(\"Notification click Received.\", event);\n+    event.notification.close();\n+    event.waitUntil(clients.openWindow(\n+});\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 2ff66eb..fe38358 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -24,6 +24,7 @@\n     <div class=\"logo\">Snek</div>\n     <nav>\n       <a href=\"/web.html\">\ud83c\udfe0</a>\n+      <a href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a href=\"/web.html\">\ud83d\udc65</a>\n@@ -52,7 +53,7 @@ let installPrompt = null\n         window.addEventListener(\"beforeinstallprompt\", async(event) => {\n             event.preventDefault();\n             installPrompt = event;\n+            document.addEventListener(\"DOMContentLoaded\", () => {\n             alert(\"Jaaah\") \n             const button = document.getElementById(\"install-button\")\n             button.addEventListener(\"click\", async ()=>{  \n@@ -61,7 +62,8 @@ let installPrompt = null\n             })\n             button.style.display = 'inline-block'\n             \n-          });\n+            })\n+        });\n         ;\n     </script>\n </body>\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\nnew file mode 100644\nindex 0000000..9eee36c\n--- /dev/null\n+++ b/src/snek/templates/search-user.html\n@@ -0,0 +1,8 @@\n+{% extends \"app.html\" %}\n+\n+{% block title %}Search{% endblock %}\n+\n+{% block main %}    \n+    <h1>Search user</h1>\n+    <generic-form class=\"center\" url=\"/search_user.json\"></generic-form>\n+{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nnew file mode 100644\nindex 0000000..4fa890c\n--- /dev/null\n+++ b/src/snek/view/search_user.py\n@@ -0,0 +1,21 @@\n+from aiohttp import web\n+\n+from snek.form.search_user import SearchUserForm\n+from snek.system.view import BaseFormView\n+\n+\n+class SearchUserView(BaseFormView):\n+    form = SearchUserForm\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\"login.html\")\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            \n+            return {\"redirect_url\": \"/search-user.html?query=\" + form.query.value}\n+        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Implemented search user form", "commit": "49eb76dc8b93cd422a9fda40cece480a573b8524", "diff": "commit 49eb76dc8b93cd422a9fda40cece480a573b8524\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:33:02 2025 +0100\n\n    Added search form.\n\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nnew file mode 100644\nindex 0000000..6f431f0\n--- /dev/null\n+++ b/src/snek/form/search_user.py\n@@ -0,0 +1,19 @@\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SearchUserForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n+\n+    username = FormInputElement(\n+        name=\"username\",\n+        required=True,\n+        min_length=1,\n+        max_length=128,\n+        place_holder=\"Username\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n+    )\n+"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Prevent potentially harmful queries via input sanitization", "commit": "60ca3ec7918073a2fb3ebe81e9ea733225391d99", "diff": "commit 60ca3ec7918073a2fb3ebe81e9ea733225391d99\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 17:59:38 2025 +0100\n\n    Added search form.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d4b2e59..2a1963a 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -113,7 +113,7 @@ class RPCView(BaseView):\n             print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase:\n+            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase or 'replace' in lowercase or 'insert' in lowercase or 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n             records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Improved styling and form handling in search user view", "commit": "5154811b29ced87375ac457fafeb25305f64a954", "diff": "commit 5154811b29ced87375ac457fafeb25305f64a954\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 21:54:46 2025 +0100\n\n    CSS Fixes.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 5db5aa9..bec7c5b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -64,10 +64,34 @@ header nav a {\n   transition: color 0.3s;\n }\n \n+\n header nav a:hover {\n }\n \n+a {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+}\n+\n+.chat-area ul {\n+    margin: 0px;\n+    padding: 0px;\n+li {\n+  font-weight: bold;\n+  margin-bottom: 3px;\n+    list-style: none;\n+      padding: 0;\n+      margin: 0; \n+      font-size: 1.5em;\n+    a {\n+        text-decoration: none;\n+    }\n+  \n+}\n+}\n main {\n   display: flex;\n   flex: 1;\n@@ -198,6 +222,16 @@ message-list {\n }\n \n+input[type=\"text\"] {\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n+}\n+\n .chat-input textarea {\n   flex: 1;\ndiff --git a/src/snek/static/fancy-button.js b/src/snek/static/fancy-button.js\nindex 948db17..310f340 100644\n--- a/src/snek/static/fancy-button.js\n+++ b/src/snek/static/fancy-button.js\n@@ -55,9 +55,16 @@ class FancyButton extends HTMLElement {\n         this.shadowRoot.appendChild(this.container);\n \n         this.url = this.getAttribute('url');\n+       \n+\n         this.value = this.getAttribute('value');\n         this.buttonElement.appendChild(document.createTextNode(this.getAttribute(\"text\")));\n         this.buttonElement.addEventListener(\"click\", () => {\n+            if(this.url == 'submit'){\n+                this.closest('form').submit()\n+                return\n+            }    \n+        \n             if (this.url === \"/back\" || this.url === \"/back/\") {\n                 window.history.back();\n             } else if (this.url) {\n@@ -67,4 +74,4 @@ class FancyButton extends HTMLElement {\n     }\n }\n \n-customElements.define(\"fancy-button\", FancyButton);\n\\ No newline at end of file\n+customElements.define(\"fancy-button\", FancyButton);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex fe38358..81d5611 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -6,7 +6,9 @@\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n   <script src=\"/push.js\"></script>\n+  <script src=\"/fancy-button.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n+  <script src=\"/generic-form.js\"></script>\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\nindex 9eee36c..34fe525 100644\n--- a/src/snek/templates/search-user.html\n+++ b/src/snek/templates/search-user.html\n@@ -3,6 +3,24 @@\n {% block title %}Search{% endblock %}\n \n {% block main %}    \n-    <h1>Search user</h1>\n-    <generic-form class=\"center\" url=\"/search_user.json\"></generic-form>\n+\n+    <section class=\"chat-area\">\n+        <div class=\"chat-header\"><h2>Search user</h2></div>\n+        <div class=\"chat-messages\">\n+        <form method=\"get\" action=\"/search-user.html\">\n+            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n+            <fancy-button size=\"auto\"  text=\"Back\" url=\"submit\"></fancy-button>\n+        </form>\n+        <ul>\n+        {% for user in users %}\n+    <li>\n+        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+    </li>\n+    \n+    {% endfor %}\n+    </ul> \n+\n+ \n+</div>\n+    </section>\n {% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 4fa890c..15e9c33 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -8,14 +8,20 @@ class SearchUserView(BaseFormView):\n     form = SearchUserForm\n \n     async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n+        users = []\n+        query = self.request.query.get(\"query\")\n+        if query:\n+            users = await self.app.services.user.search(query)\n+            print(users,flush=True) \n+\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\")\n+        return await self.render_template(\"search-user.html\",dict(users=users,query=query or ''))\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            \n-            return {\"redirect_url\": \"/search-user.html?query=\" + form.query.value}\n+            print(\"YEAAAH\\n\") \n+            return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n         return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-02-08", "line": "style: Adjusted chat message container styling", "commit": "a8fea31a326c7b9868dde866be553bb9f84eee88", "diff": "commit a8fea31a326c7b9868dde866be553bb9f84eee88\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 22:03:26 2025 +0100\n\n    Fixed..\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex bec7c5b..aa6a06d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -156,6 +156,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n+  \n   padding: 10px;\n   height: 200px;\n@@ -172,7 +173,7 @@ message-list {\n   align-items: flex-start;\n   margin-bottom: 0px;\n   padding: 5px;\n+  \n   border-radius: 8px;\n }\n@@ -190,7 +191,6 @@ message-list {\n   align-items: center;\n   margin-right: 15px;\n }\n-\n .chat-messages .message .message-content {\n   flex: 1;\n }"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Optimize database performance with WAL mode", "commit": "06b539b8845c49ec6d2789876ef4069b8df77117", "diff": "commit 06b539b8845c49ec6d2789876ef4069b8df77117\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 8 23:48:42 2025 +0100\n\n    Fixed..\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6d0d1e6..aaf1029 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -58,6 +58,8 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n+        self.db.query(\"PRAGMA journal_mode=WAL\")\n+        self.db.query(\"PRAGMA syncnorm=off\")\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection in UI elements", "commit": "b169fa4792e02303ea0f61e5d83b3993a8f72f05", "diff": "commit b169fa4792e02303ea0f61e5d83b3993a8f72f05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:31:34 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex aa6a06d..e57b944 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -63,7 +63,12 @@ header nav a {\n   font-size: 1em;\n   transition: color 0.3s;\n }\n-\n+.no-select {\n+    }\n \n header nav a:hover {\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 17f3066..91d80d3 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -74,6 +74,7 @@ class MessageListElement extends HTMLElement {\n \n         const avatar = document.createElement(\"div\");\n         avatar.classList.add(\"avatar\");\n+        avatar.classList.add(\"no-select\");\n         avatar.style.backgroundColor = message.color;\n         avatar.style.color = \"black\";\n         avatar.innerText = message.user_nick[0];\n@@ -166,4 +167,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\n\\ No newline at end of file\n+customElements.define('message-list', MessageListElement);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 81d5611..8d2adca 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -25,23 +25,23 @@\n   <header>\n     <div class=\"logo\">Snek</div>\n     <nav>\n-      <a href=\"/web.html\">\ud83c\udfe0</a>\n-      <a href=\"/search-user.html\">\ud83d\udd0d</a>\n-      <a href=\"/web.html\">\ud83d\udc65</a>\n-      <a href=\"/logout.html\">\ud83d\udd12</a>\n+      <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n+      <a class=\"no-select\" href=\"/web.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n   </header>\n   <main>\n     {% block sidebar %}\n     <aside class=\"sidebar\">\n-      <h2>Chat Rooms</h2>\n+      <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n        \n       </ul>\n     </aside>"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection on header elements", "commit": "ad4847a78e2945fe4f57ae16262e0c2a91a804f5", "diff": "commit ad4847a78e2945fe4f57ae16262e0c2a91a804f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:34:02 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 0b341dc..1e6f9fb 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -41,6 +41,7 @@ class ChatWindowElement extends HTMLElement {\n \n         const chatTitle = document.createElement('h2');\n         chatTitle.classList.add(\"chat-title\");\n+        chatTitle.classList.add(\"no-select\");\n         chatTitle.innerText = \"Loading...\";\n         chatHeader.appendChild(chatTitle);\n         this.container.appendChild(chatHeader);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 8d2adca..df2c555 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -23,7 +23,7 @@\n </head>\n <body>\n   <header>\n-    <div class=\"logo\">Snek</div>\n+    <div class=\"no-select logo\">Snek</div>\n     <nav>\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection in navigation", "commit": "bda5cfd52d5272742d147e93b506b87eeee04e1e", "diff": "commit bda5cfd52d5272742d147e93b506b87eeee04e1e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:38:01 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex df2c555..7412c17 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -24,7 +24,7 @@\n <body>\n   <header>\n     <div class=\"no-select logo\">Snek</div>\n-    <nav>\n+    <nav class=\"no-select\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Updated template name in search user view", "commit": "afa40ada778c5d4102bce19312456adca51f70d0", "diff": "commit afa40ada778c5d4102bce19312456adca51f70d0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:42:34 2025 +0100\n\n    No select.\n\ndiff --git a/src/snek/templates/search-user.html b/src/snek/templates/search-user.html\ndeleted file mode 100644\nindex 34fe525..0000000\n--- a/src/snek/templates/search-user.html\n+++ /dev/null\n@@ -1,26 +0,0 @@\n-{% extends \"app.html\" %}\n-\n-{% block title %}Search{% endblock %}\n-\n-{% block main %}    \n-\n-    <section class=\"chat-area\">\n-        <div class=\"chat-header\"><h2>Search user</h2></div>\n-        <div class=\"chat-messages\">\n-        <form method=\"get\" action=\"/search-user.html\">\n-            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n-            <fancy-button size=\"auto\"  text=\"Back\" url=\"submit\"></fancy-button>\n-        </form>\n-        <ul>\n-        {% for user in users %}\n-    <li>\n-        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n-    </li>\n-    \n-    {% endfor %}\n-    </ul> \n-\n- \n-</div>\n-    </section>\n-{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 15e9c33..292e135 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -18,7 +18,7 @@ class SearchUserView(BaseFormView):\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"search-user.html\",dict(users=users,query=query or ''))\n+        return await self.render_template(\"search_user.html\",dict(users=users,query=query or ''))\n \n     async def submit(self, form):\n         if await form.is_valid:"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Implemented user search functionality with basic UI", "commit": "a42c2bdf5d2cee53c16e8dc123fc4473107ef203", "diff": "commit a42c2bdf5d2cee53c16e8dc123fc4473107ef203\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 01:42:50 2025 +0100\n\n    Added search user.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nnew file mode 100644\nindex 0000000..ba5b51b\n--- /dev/null\n+++ b/src/snek/templates/search_user.html\n@@ -0,0 +1,26 @@\n+{% extends \"app.html\" %}\n+\n+{% block title %}Search{% endblock %}\n+\n+{% block main %}    \n+\n+    <section class=\"chat-area\">\n+        <div class=\"chat-header\"><h2>Search user</h2></div>\n+        <div class=\"chat-messages\">\n+        <form method=\"get\" action=\"/search-user.html\">\n+            <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n+            <fancy-button size=\"auto\"  text=\"Search\" url=\"submit\"></fancy-button>\n+        </form>\n+        <ul>\n+        {% for user in users %}\n+    <li>\n+        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+    </li>\n+    \n+    {% endfor %}\n+    </ul> \n+\n+ \n+</div>\n+    </section>\n+{% endblock %}"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent SQL injection by enhancing security checks", "commit": "e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f", "diff": "commit e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 02:07:29 2025 +0100\n\n    Updated sql security.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 2a1963a..158ed21 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -113,7 +113,7 @@ class RPCView(BaseView):\n             print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if \"drop\" in lowercase or \"alter\" in lowercase or \"update\" in lowercase or \"delete\" in lowercase or 'replace' in lowercase or 'insert' in lowercase or 'select' not in lowercase:\n+            if any([\"drop\" in lowercase, \"alter\" in lowercase,\"update\" in lowercase, \"delete\" in lowercase, 'replace' in lowercase , 'insert' in lowercase , 'truncate' in lowercase , 'select' not in lowercase]):\n                 raise Exception(\"Not allowed\")\n             records = [dict(record) async for record in self.services.channel.query(args[0])]\n             return records"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added branding elements to various views.", "commit": "a3cec5bce0386c8c3012262aa4a582241786220d", "diff": "commit a3cec5bce0386c8c3012262aa4a582241786220d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 02:25:06 2025 +0100\n\n    Branding.\n\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 762fc8e..b03f922 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,13 +1,37 @@\n-from snek.system.view import BaseView\n \n \n-class AboutHTMLView(BaseView):\n \n+\n+from snek.system.view import BaseView\n+\n+class AboutHTMLView(BaseView):\n+   \n     async def get(self):\n         return await self.render_template(\"about.html\")\n \n-\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n+        return await self.render_template(\"about.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex 519a0eb..592d1a2 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,13 +1,35 @@\n+  \n+  \n+  \n+  \n+  \n from snek.system.view import BaseView\n \n-\n class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n \n-\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n+        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex bd91dc8..3e62518 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,7 +1,17 @@\n-from snek.system.view import BaseView\n \n \n-class IndexView(BaseView):\n+\n+\n+\n \n+\n+\n+from snek.system.view import BaseView\n+\n+class IndexView(BaseView):\n     async def get(self):\n-        return await self.render_template(\"index.html\")\n+        return await self.render_template(\"index.html\")\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex db5be55..0c2359d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,9 +1,15 @@\n-from aiohttp import web\n+\n+\n \n+\n+from aiohttp import web\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n-\n class LoginView(BaseFormView):\n     form = LoginForm\n \n@@ -15,13 +21,14 @@ class LoginView(BaseFormView):\n         return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n-        if await form.is_valid:\n-            user = await self.services.user.get(username=form['username'],deleted_at=None)\n+        if await form.is_valid():\n+            user = await self.services.user.get(username=form['username'], deleted_at=None)\n             await self.services.user.save(user)\n-            self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = user['username']\n-            self.session[\"uid\"] = user[\"uid\"]\n-            self.session[\"color\"] = user[\"color\"]\n+            self.session.update({\n+                \"logged_in\": True,\n+                \"username\": user['username'],\n+                \"uid\": user[\"uid\"],\n+                \"color\": user[\"color\"]\n+            })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex f0efce9..230d334 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,3 +1,12 @@\n+\n+\n+\n+\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n@@ -6,9 +15,9 @@ class LoginFormView(BaseFormView):\n     form = LoginForm\n \n     async def submit(self, form):\n-        if await form.is_valid:\n+        if await form.is_valid():\n             self.session[\"logged_in\"] = True\n             self.session[\"username\"] = form.username.value\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex eb5c1ae..57b92a3 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,10 +1,38 @@\n-from aiohttp import web\n+\n+\n+\n+\n \n+\n+\n+\n+from aiohttp import web\n from snek.system.view import BaseView\n \n \n class LogoutView(BaseView):\n-\n     redirect_url = \"/\"\n     login_required = True\n \n@@ -24,4 +52,4 @@ class LogoutView(BaseView):\n             del self.session[\"username\"]\n         except KeyError:\n             pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex c29d855..fdcc9ad 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,11 +1,16 @@\n-from aiohttp import web\n+\n+\n+\n \n+from aiohttp import web\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n-\n class RegisterView(BaseFormView):\n-\n     form = RegisterForm\n \n     async def get(self):\n@@ -23,4 +28,4 @@ class RegisterView(BaseFormView):\n         self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n         self.request.session[\"color\"] = result[\"color\"]\n-        return {\"redirect_url\": \"/web.html\"}\n+        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 89fa3ac..858edad 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,3 +1,30 @@\n+\n+\n+\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n@@ -10,7 +37,7 @@ class RegisterFormView(BaseFormView):\n             form.email.value, form.username.value, form.password.value\n         )\n         self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"usernmae\"]\n+        self.request.session[\"username\"] = result[\"username\"]\n         self.request.session[\"logged_in\"] = True\n \n-        return {\"redirect_url\": \"/web.html\"}\n+        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 158ed21..53e5aab 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,3 +1,12 @@\n+\n+\n+\n+\n+\n from aiohttp import web  \n from snek.system.view import BaseView\n import traceback\n@@ -6,7 +15,7 @@ import json\n class RPCView(BaseView):\n \n     class RPCApi:\n-        def __init__(self,view, ws):\n+        def __init__(self, view, ws):\n             self.view = view \n             self.app = self.view.app\n             self.services = self.app.services\n@@ -15,7 +24,6 @@ class RPCView(BaseView):\n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")\n-            \n \n         @property \n         def request(self):\n@@ -42,9 +50,8 @@ class RPCView(BaseView):\n             del record['password']\n             del record['deleted_at']\n             await self.services.socket.add(self.ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(self.ws,subscription[\"channel_uid\"])\n-       \n+            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"])\n             return record \n \n         async def search_user(self, query): \n@@ -59,66 +66,59 @@ class RPCView(BaseView):\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            if not user_uid == user[\"uid\"]:\n+            if user_uid != user[\"uid\"]:\n                 del record['email']\n             return record \n-        async def get_messages(self, channel_uid,offset=0):\n+\n+        async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-                \n+            async for message in self.services.channel_message.query(\"SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 60\"):\n                 user = await self.services.user.get(uid=message[\"user_uid\"])\n                 if not user:\n-                    print(\"User not found!\",flush= True)\n+                    print(\"User not found!\", flush=True)\n                     continue\n-\n-                messages.insert(0,dict(\n-                    uid=message[\"uid\"],\n-                    color=user['color'],\n-                    user_uid=message[\"user_uid\"],\n-                    channel_uid=message[\"channel_uid\"],\n-                    user_nick=user['nick'],\n-                    message=message[\"message\"],\n-                    created_at=message[\"created_at\"],\n-                    html=message['html'],\n-                    username=user['username'] \n-                ))\n-                print(\"Response messages:\",messages,flush=True)\n+                messages.insert(0, {\n+                    \"uid\": message[\"uid\"],\n+                    \"color\": user['color'],\n+                    \"user_uid\": message[\"user_uid\"],\n+                    \"channel_uid\": message[\"channel_uid\"],\n+                    \"user_nick\": user['nick'],\n+                    \"message\": message[\"message\"],\n+                    \"created_at\": message[\"created_at\"],\n+                    \"html\": message['html'],\n+                    \"username\": user['username'] \n+                })\n             return messages\n-        \n+\n         async def get_channels(self):\n             self._require_login()\n             channels = []\n-            async for subscription in self.services.channel_member.find(user_uid=self.user_uid,is_banned=False):\n-                channels.append(dict(\n-                    name=subscription[\"label\"],\n-                    uid=subscription[\"channel_uid\"],\n-                    is_moderator=subscription[\"is_moderator\"],\n-                    is_read_only=subscription[\"is_read_only\"]\n-                ))\n+            async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n+                channels.append({\n+                    \"name\": subscription[\"label\"],\n+                    \"uid\": subscription[\"channel_uid\"],\n+                    \"is_moderator\": subscription[\"is_moderator\"],\n+                    \"is_read_only\": subscription[\"is_read_only\"]\n+                })\n             return channels\n \n         async def send_message(self, room, message):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid,room,message)\n+            await self.services.chat.send(self.user_uid, room, message)\n             return True \n-        \n \n-        async def echo(self,*args):\n+        async def echo(self, *args):\n             self._require_login()\n             return args\n \n-        async def query(self,*args):\n+        async def query(self, *args):\n             self._require_login()\n-            print(args,flush=True)\n             query = args[0] \n             lowercase = query.lower()\n-            if any([\"drop\" in lowercase, \"alter\" in lowercase,\"update\" in lowercase, \"delete\" in lowercase, 'replace' in lowercase , 'insert' in lowercase , 'truncate' in lowercase , 'select' not in lowercase]):\n+            if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) async for record in self.services.channel.query(args[0])]\n-            return records \n-\n-\n+            return [dict(record) async for record in self.services.channel.query(args[0])]\n \n         async def __call__(self, data):\n             try:\n@@ -126,58 +126,45 @@ class RPCView(BaseView):\n                 method_name = data.get(\"method\")\n                 if method_name.startswith(\"_\"):\n                     raise Exception(\"Not allowed\")\n-                args = data.get(\"args\")\n-                if hasattr(super(),method_name) or not hasattr(self,method_name):\n-                    return await self._send_json({\"callId\":call_id,\"data\":\"Not allowed\"})\n-                method = getattr(self,method_name.replace(\".\",\"_\"),None)\n+                args = data.get(\"args\") or []\n+                if hasattr(super(), method_name) or not hasattr(self, method_name):\n+                    return await self._send_json({\"callId\": call_id, \"data\": \"Not allowed\"})\n+                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n                 success = True \n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n-                    result = dict({\"exception\":str(ex),\"traceback\":traceback.format_exc()})\n+                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False \n-                    print(result,flush=True)\n-                await self._send_json({\"callId\":call_id,\"success\":success,\"data\":result})\n+                await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n-                await self._send_json({\"callId\":call_id,\"success\":False,\"data\":str(ex)})\n+                await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n-        async def _send_json(self,obj):\n-            await self.ws.send_str(json.dumps(obj,default=str))\n+        async def _send_json(self, obj):\n+            await self.ws.send_str(json.dumps(obj, default=str))\n \n-        async def call_ping(self,callId,*args):\n+        async def call_ping(self, callId, *args):\n             return {\"pong\": args}\n \n-\n     async def get(self):\n-\n-        \n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\") is True:\n+        if self.request.session.get(\"logged_in\"):\n             await self.services.socket.add(ws)\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"),deleted_at=None,is_banned=False):\n-                await self.services.socket.subscribe(ws,subscription[\"channel_uid\"])\n-        rpc = RPCView.RPCApi(self,ws)\n+            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"])\n+        rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n-            print(msg,flush=True)\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     await rpc(msg.json())\n                 except Exception as ex:\n-                    print(ex,flush=True)\n-                    print(traceback.format_exc(),flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n-                print(f\"WebSocket exception {ws.exception()}\")\n                 pass \n             elif msg.type == web.WSMsgType.CLOSE:\n                 pass \n-        print(\"WebSocket connection closed\")\n-        return ws\n+        return ws\n\\ No newline at end of file\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 292e135..04e9080 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,5 +1,34 @@\n-from aiohttp import web\n+\n+\n+\n \n+\n+from aiohttp import web\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n \n@@ -8,20 +37,19 @@ class SearchUserView(BaseFormView):\n     form = SearchUserForm\n \n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n             users = await self.app.services.user.search(query)\n-            print(users,flush=True) \n+            print(users, flush=True)\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"search_user.html\",dict(users=users,query=query or ''))\n+\n+        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or ''})\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            print(\"YEAAAH\\n\") \n+            print(\"YES\\n\")\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 04ea4d9..9428f08 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,18 +1,44 @@\n-from snek.system.view import BaseView\n+\n+\n+\n \n \n+from snek.system.view import BaseView\n+\n class StatusView(BaseView):\n     async def get(self):\n-\n         memberships = []\n         user = {}\n-\n-        if self.session.get(\"uid\"):\n-            user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        \n+        user_id = self.session.get(\"uid\")\n+        if user_id:\n+            user = await self.app.services.user.get(uid=user_id)\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n+            \n             async for model in self.app.services.channel_member.find(\n-                user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False\n+                user_uid=user_id, deleted_at=None, is_banned=False\n             ):\n                 channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n                 memberships.append(\n@@ -33,7 +59,7 @@ class StatusView(BaseView):\n                 \"email\": user[\"email\"],\n                 \"nick\": user[\"nick\"],\n                 \"uid\": user[\"uid\"],\n-                \"color\": user['color'],\n+                \"color\": user[\"color\"],\n                 \"memberships\": memberships,\n             }\n \n@@ -44,4 +70,4 @@ class StatusView(BaseView):\n                     self.app.cache.cache, None\n                 ),\n             }\n-        )\n+        )\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex ff208e9..2d7d98b 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,8 +1,20 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n-import aiofiles \n+import aiofiles\n import pathlib\n from aiohttp import web\n-import uuid \n+import uuid\n \n UPLOAD_DIR = pathlib.Path(\"./drive\")\n \n@@ -11,24 +23,23 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-        \n-        print(await drive_item.to_json(),flush=True)\n-        return web.FileResponse(drive_item[\"path\"])       \n+\n+        print(await drive_item.to_json(), flush=True)\n+        return web.FileResponse(drive_item[\"path\"])\n \n     async def post(self):\n         reader = await self.request.multipart()\n-        files = []  \n+        files = []\n \n         UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n \n-        channel_uid = None \n+        channel_uid = None\n \n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n-        print(str(drive),flush=True)\n+        print(str(drive), flush=True)\n \n         while field := await reader.next():\n-\n             if field.name == \"channel_uid\":\n                 channel_uid = await field.text()\n                 continue\n@@ -36,21 +47,21 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n-            \n+\n             file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n             files.append(file_path)\n- \n+\n             async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n+            drive_item = await self.services.drive_item.create(\n+                drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n+            )\n \n-            drive_item = await self.services.drive_item.create(drive[\"uid\"],filename,str(file_path.absolute()),file_path.stat().st_size,file_path.suffix)\n-\n-            await self.services.chat.send(self.request.session.get(\"uid\"),channel_uid,f\"![{filename}](/drive.bin/{drive_item['uid']})\")\n-            print(drive_item,flush=True)\n-\n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files],\"channel_uid\":channel_uid})\n-\n+            await self.services.chat.send(\n+                self.request.session.get(\"uid\"), channel_uid, f\"![{filename}](/drive.bin/{drive_item['uid']})\"\n+            )\n+            print(drive_item, flush=True)\n \n-        \n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 63bacab..e172b8d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,8 +1,33 @@\n-from snek.system.view import BaseView\n \n+\n+\n \n-class WebView(BaseView):\n \n+from snek.system.view import BaseView\n+\n+class WebView(BaseView):\n     login_required = True\n \n     async def get(self):"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added WebView and channel routing for web interface", "commit": "78f9679f308016320b64cd49bb3552fb63d26d27", "diff": "commit 78f9679f308016320b64cd49bb3552fb63d26d27\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 07:54:46 2025 +0100\n\n    Bugfixes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex aaf1029..3020584 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -91,6 +91,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel}.html\", WebView)\n+\n         self.add_subapp(\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 0c2359d..580655f 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -21,7 +21,7 @@ class LoginView(BaseFormView):\n         return await self.render_template(\"login.html\")\n \n     async def submit(self, form):\n-        if await form.is_valid():\n+        if await form.is_valid:\n             user = await self.services.user.get(username=form['username'], deleted_at=None)\n             await self.services.user.save(user)\n             self.session.update({\n@@ -31,4 +31,4 @@ class LoginView(BaseFormView):\n                 \"color\": user[\"color\"]\n             })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex e172b8d..4923183 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -24,11 +24,50 @@\n \n-\n+from aiohttp import web\n from snek.system.view import BaseView\n \n class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n-        return await self.render_template(\"web.html\")\n+\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+\n+        if not self.request.match_info.get(\"channel\"):\n+            channel = await self.app.services.channel.get(\n+                    tag=\"public\",deleted_at=None\n+            )\n+            if not channel:\n+                return web.HTTPNotFound()\n+            return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        else:\n+            print(self.request.match_info.get(\"channel\"), flush=True)\n+            channel = await self.app.services.channel.get(\n+                uid=str(self.request.match_info.get(\"channel\")),deleted_at=None\n+            )\n+            if not channel:\n+\n+                \n+                print(\"TADAAA:\",name, flush=True)\n+\n+                channel = await self.app.services.channel.get(\n+                    label=name,deleted_at=None\n+                )\n+            if not channel:\n+                print(\"NOT found!\\n\",flush=True)\n+                return web.HTTPNotFound()\n+            channel_member = await self.app.services.channel_member.get(\n+                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False\n+            )\n+            if not channel_member:\n+                return web.HTTPNotFound()\n+           \n+        print(\"HIER\\n\",flush=True) \n+        user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        if not user:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user})"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Add time descriptions to messages and improve chat layout", "commit": "e7cd397e0fe98074833e08880d915516718adaf5", "diff": "commit e7cd397e0fe98074833e08880d915516718adaf5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 12:35:38 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex dcc12d5..309e959 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -22,7 +22,8 @@ class ChannelMessageService(BaseService):\n         context.update(dict(\n             user_uid=user['uid'],\n             username=user['username'],\n-            user_nick=user['nick']\n+            user_nick=user['nick'],\n+            color=user['color']\n         ))\n         try:\n             template = self.app.jinja2_env.get_template(\"message.html\")\n@@ -34,4 +35,27 @@ class ChannelMessageService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n     \n-    \n+    async def to_extended_dict(self, message):\n+        user = await self.services.user.get(uid=message[\"user_uid\"])\n+        if not user:\n+            print(\"User not found!\", flush=True)\n+            return {}\n+        return {\n+            \"uid\": message[\"uid\"],\n+            \"color\": user['color'],\n+            \"user_uid\": message[\"user_uid\"],\n+            \"channel_uid\": message[\"channel_uid\"],\n+            \"user_nick\": user['nick'],\n+            \"message\": message[\"message\"],\n+            \"created_at\": message[\"created_at\"],\n+            \"html\": message['html'],\n+            \"username\": user['username'] \n+        }\n+\n+    async def offset(self, channel_uid, offset=0):\n+        results = []\n+\n+        async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+            results.append(model)\n+        results.sort(key=lambda x: x['created_at'])\n+        return results \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 2f3e610..a0f83c2 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -289,7 +289,7 @@ class App extends EventHandler {\n         this.audio = new NotificationAudio(500);\n         const me = this \n         this.ws.addEventListener(\"channel-message\", (data) => {\n-            me.emit(data.channel_uid, data);\n+            me.emit(\"channel-message\", data);\n         });\n \n         this.rpc.getUser(null).then(user => {\n@@ -300,7 +300,31 @@ class App extends EventHandler {\n     playSound(index) {\n         this.audio.play(index);\n     }\n-\n+    timeDescription(isoDate) {\n+        const date = new Date(isoDate);\n+        const hours = String(date.getHours()).padStart(2, \"0\");\n+        const minutes = String(date.getMinutes()).padStart(2, \"0\");\n+        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n+        return timeStr;\n+    }\n+    timeAgo(date1, date2) {\n+        const diffMs = Math.abs(date2 - date1);\n+        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n+        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));\n+        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));\n+        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);\n+\n+        if (days) {\n+            return `${days} ${days > 1 ? 'days' : 'day'} ago`;\n+        }\n+        if (hours) {\n+            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;\n+        }\n+        if (minutes) {\n+            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;\n+        }\n+        return 'just now';\n+    }\n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n         const me = this; \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e57b944..e069ff7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -161,7 +161,7 @@ message-list {\n }\n .chat-messages {\n   flex: 1;\n-  \n+  overflow-y: auto;\n   padding: 10px;\n   height: 200px;\n@@ -211,9 +211,20 @@ message-list {\n   word-break: break-word;\n   overflow-wrap: break-word;\n+  display: block;\n \n }\n-\n+.message-content {\n+  width: 100%;\n+    display:block;\n+    p {\n+        display: block;\n+        width:100%;\n+    }\n+}\n+.message-content img {\n+   max-width: 100%; \n+}\n .chat-messages .message .message-content .time {\n   font-size: 0.8em;\ndiff --git a/src/snek/static/chat-window.js b/src/snek/static/chat-window.js\nindex 1e6f9fb..62ed2ee 100644\n--- a/src/snek/static/chat-window.js\n+++ b/src/snek/static/chat-window.js\n@@ -73,7 +73,8 @@ class ChatWindowElement extends HTMLElement {\n         const me = this;\n         channelElement.addEventListener(\"message\", (message) => {\n             if (me.user.uid !== message.detail.user_uid) app.playSound(0);\n-            message.detail.element.scrollIntoView();\n+            \n+            message.detail.element.scrollIntoView({\"block\": \"end\"});\n         });\n     }\n }\ndiff --git a/src/snek/static/push.js b/src/snek/static/push.js\nindex 806a51e..ca6a8a3 100644\n--- a/src/snek/static/push.js\n+++ b/src/snek/static/push.js\n@@ -5,7 +5,7 @@ this.onpush = (event) => {\n         };\n \n         navigator.serviceWorker\n-            .register(\"service-worker.js\")\n+            .register(\"/service-worker.js\")\n             .then((serviceWorkerRegistration) => {\n                 serviceWorkerRegistration.pushManager.subscribe().then(\n                     (pushSubscription) => {\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 6e3052f..c399d2b 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -21,7 +21,7 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n-        formData.append('channel_uid', this.chatInput.channelUid);\n+        formData.append('channel_uid', this.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n@@ -105,7 +105,7 @@ class UploadButtonElement extends HTMLElement {\n             </div>\n         `;\n         this.shadowRoot.appendChild(this.container);\n-\n+        this.channelUid = this.getAttribute('channel');\n         this.uploadButton = this.container.querySelector('.upload-button');\n         this.fileInput = this.container.querySelector('.hidden-input');\n         this.uploadButton.addEventListener('click', () => {\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 89496a0..a543dd7 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -16,6 +16,7 @@ def set_link_target_blank(text):\n         element.attrs['target'] = '_blank'\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n+        element.attrs['href'] = element.attrs['href'].strip(\".\")\n \n     return str(soup)\n     \n@@ -23,7 +24,7 @@ def set_link_target_blank(text):\n def linkify_https(text):\n         return text \n \n     soup = BeautifulSoup(text, 'html.parser')\n \ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 7412c17..07fda6d 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -12,11 +12,6 @@\n   <script src=\"/html-frame.js\"></script>\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n-  <script src=\"/models.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <script src=\"/message-list-manager.js\"></script>\n-  <script src=\"/chat-input.js\"></script>\n-  <script src=\"/chat-window.js\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n   <link rel=\"manifest\" href=\"/manifest.json\" />\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex c271db3..8c43c49 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0225965..4078d92 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,4 +1,99 @@\n {% extends \"app.html\" %} \n {% block main %}\n-    <chat-window class=\"chat-area\"></chat-window>\n+    <section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\"><h2>{{ channel.label.value }}</h2></div>\n+    <div class=\"chat-messages\">\n+        \n+        {% for message in messages %}\n+        {% autoescape false %}\n+        {{message.html}}\n+        {% endautoescape %}\n+        <div style=\"display:none\" class=\"message {% if loop.first or message.username != messages[loop.index0 - 1].username %}switch-user{% endif %}\" data-uid=\"{{message.uid}}\" data-color=\"{{message.color}}\" data-user_nick=\"{{message.user_nick}}\" data-created_at=\"{{message.created_at}}\">\n+                <div class=\"avatar no-select\" style=\"background-color: {{message.color}}; color: black;\">{{message.user_nick[0]}}</div> \n+            <div class=\"message-content\">\n+        {% autoescape false %}\n+                {{ message.html }}\n+            \n+        {% endautoescape %}\n+            <div class=\"time no-select\" data-created_at=\"{{message.created_at}}\">{{message.created_at}}</div>\n+            </div>\n+        </div>\n+        {% endfor %}\n+    </div>\n+    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n+    <div class=\"chat-input\">\n+      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+      <upload-button channel=\"{{channel.channel_uid.value}}\"></upload-button>\n+    </div>\n+    </section>\n+    <script>\n+        const channelUid = \"{{channel.channel_uid.value}}\"\n+\n+        function initInputField(textBox){\n+\n+            textBox.addEventListener('change', (e) => {\n+      e.preventDefault();\n+      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+\n+    });\n+\n+    textBox.addEventListener('keydown', (e) => {\n+      if (e.key === 'Enter' && !e.shiftKey) {\n+        e.preventDefault();\n+        const message = e.target.value.trim();\n+        if (!message) return;\n+            app.rpc.sendMessage(channelUid, e.target.value)\n+          e.target.value = '';\n+      }\n+    });\n+        }\n+        initInputField(document.querySelector(\"textarea\"))\n+        function updateTimes(){\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+                time.innerText = app.timeDescription(time.dataset.created_at)\n+        })\n+        }\n+        function updateLayout() {\n+\n+             document.querySelectorAll(\".chat-messages\").forEach((messages) => messages.scrollTop = messages.scrollHeight + 1000)\n+            updateTimes()\n+            let previousUser = null;\n+            document.querySelectorAll(\".message\").forEach((message) => {\n+                if(previousUser != message.dataset.user_uid) {\n+                    message.classList.add(\"switch-user\")\n+                    previousUser = message.dataset.user_uid\n+                }else{\n+                    message.classList.remove(\"switch-user\")\n+                }\n+            })\n+        }\n+         setInterval(() => {\n+                \n+            \n+                updateTimes()\n+\n+            }, 1000)\n+\n+        app.addEventListener(\"channel-message\", (data) => {\n+            if(data.channel_uid != channelUid) return\n+            if(data.username != \"{{user.username.value}}\"){\n+                app.playSound(0)\n+            }\n+            const message = document.createElement(\"div\")\n+            message.dataset.color = data.color\n+            message.dataset.created_at = data.created_at\n+            message.dataset.user_nick = data.user_nick\n+            message.dataset.uid = data.uid\n+            message.innerHTML = data.html\n+            document.querySelector(\".chat-messages\").appendChild(message.firstChild)\n+            setTimeout(()=>{\n+            updateLayout()\n+            },50)\n+        })\n+        updateLayout()\n+    </script>\n {% endblock %}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 53e5aab..9aa89f5 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -73,22 +73,11 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-            async for message in self.services.channel_message.query(\"SELECT * FROM channel_message ORDER BY created_at DESC LIMIT 60\"):\n-                user = await self.services.user.get(uid=message[\"user_uid\"])\n-                if not user:\n-                    print(\"User not found!\", flush=True)\n-                    continue\n-                messages.insert(0, {\n-                    \"uid\": message[\"uid\"],\n-                    \"color\": user['color'],\n-                    \"user_uid\": message[\"user_uid\"],\n-                    \"channel_uid\": message[\"channel_uid\"],\n-                    \"user_nick\": user['nick'],\n-                    \"message\": message[\"message\"],\n-                    \"created_at\": message[\"created_at\"],\n-                    \"html\": message['html'],\n-                    \"username\": user['username'] \n-                })\n+            print(\"Channel uid:\", channel_uid, flush=True)\n+            for message in await self.services.channel_message.offset(channel_uid, offset):\n+                print(message, flush=True)\n+                extended_dict = await self.services.channel_message.to_extended_dict(message)\n+                messages.append(extended_dict)\n             return messages\n \n         async def get_channels(self):\n@@ -167,4 +156,4 @@ class RPCView(BaseView):\n                 pass \n             elif msg.type == web.WSMsgType.CLOSE:\n                 pass \n-        return ws\n\\ No newline at end of file\n+        return ws\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 4923183..e02d8b3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,4 +70,12 @@ class WebView(BaseView):\n         if not user:\n             return web.HTTPNotFound()\n \n-        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user})\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+\n+        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n+            channel[\"uid\"]\n+        )]\n+        \n+\n+        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user,\"messages\": messages})"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added custom scrollbar styling to chat messages", "commit": "feb5234b3b581936d45ac328b23de7da8f375ee2", "diff": "commit feb5234b3b581936d45ac328b23de7da8f375ee2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 12:44:51 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e069ff7..46517cf 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -336,3 +336,31 @@ input[type=\"text\"] {\n         display: block;\n     }\n }\n+\n+::-webkit-scrollbar {\n+}\n+\n+::-webkit-scrollbar-track {\n+}\n+\n+::-webkit-scrollbar-thumb {\n+}\n+\n+::-webkit-scrollbar-thumb:hover {\n+}\n+\n+.chat-messages{\n+  scrollbar-width: thin;\n+}"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor chat display and message handling for improved performance and user experience", "commit": "ecb77cf361f0d55b512028701c67c1e347836e6e", "diff": "commit ecb77cf361f0d55b512028701c67c1e347836e6e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:06:12 2025 +0100\n\n    So smooth.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4078d92..01c5506 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,99 +1,89 @@\n {% extends \"app.html\" %} \n+\n {% block main %}\n-    <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\"><h2>{{ channel.label.value }}</h2></div>\n+<section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">\n+        <h2>{{ channel.label.value }}</h2>\n+    </div>\n     <div class=\"chat-messages\">\n-        \n         {% for message in messages %}\n-        {% autoescape false %}\n-        {{message.html}}\n-        {% endautoescape %}\n-        <div style=\"display:none\" class=\"message {% if loop.first or message.username != messages[loop.index0 - 1].username %}switch-user{% endif %}\" data-uid=\"{{message.uid}}\" data-color=\"{{message.color}}\" data-user_nick=\"{{message.user_nick}}\" data-created_at=\"{{message.created_at}}\">\n-                <div class=\"avatar no-select\" style=\"background-color: {{message.color}}; color: black;\">{{message.user_nick[0]}}</div> \n-            <div class=\"message-content\">\n-        {% autoescape false %}\n-                {{ message.html }}\n-            \n-        {% endautoescape %}\n-            <div class=\"time no-select\" data-created_at=\"{{message.created_at}}\">{{message.created_at}}</div>\n-            </div>\n-        </div>\n+            {% autoescape false %}\n+            {{ message.html }}\n+            {% endautoescape %}\n         {% endfor %}\n     </div>\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <div class=\"chat-input\">\n-      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-      <upload-button channel=\"{{channel.channel_uid.value}}\"></upload-button>\n+        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <upload-button channel=\"{{ channel.channel_uid.value }}\"></upload-button>\n     </div>\n-    </section>\n-    <script>\n-        const channelUid = \"{{channel.channel_uid.value}}\"\n+</section>\n \n-        function initInputField(textBox){\n+<script>\n+    const channelUid = \"{{ channel.channel_uid.value }}\";\n \n-            textBox.addEventListener('change', (e) => {\n-      e.preventDefault();\n-      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+    function initInputField(textBox) {\n+        textBox.addEventListener('change', (e) => {\n+            e.preventDefault();\n+            this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+        });\n \n-    });\n+        textBox.addEventListener('keydown', (e) => {\n+            if (e.key === 'Enter' && !e.shiftKey) {\n+                e.preventDefault();\n+                const message = e.target.value.trim();\n+                if (message) {\n+                    app.rpc.sendMessage(channelUid, message);\n+                    e.target.value = '';\n+                }\n+            }\n+        });\n+    }\n \n-    textBox.addEventListener('keydown', (e) => {\n-      if (e.key === 'Enter' && !e.shiftKey) {\n-        e.preventDefault();\n-        const message = e.target.value.trim();\n-        if (!message) return;\n-            app.rpc.sendMessage(channelUid, e.target.value)\n-          e.target.value = '';\n-      }\n-    });\n-        }\n-        initInputField(document.querySelector(\"textarea\"))\n-        function updateTimes(){\n+    function updateTimes() {\n         document.querySelectorAll(\".time\").forEach((time) => {\n-                time.innerText = app.timeDescription(time.dataset.created_at)\n-        })\n-        }\n-        function updateLayout() {\n+            time.innerText = app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n \n-             document.querySelectorAll(\".chat-messages\").forEach((messages) => messages.scrollTop = messages.scrollHeight + 1000)\n-            updateTimes()\n-            let previousUser = null;\n-            document.querySelectorAll(\".message\").forEach((message) => {\n-                if(previousUser != message.dataset.user_uid) {\n-                    message.classList.add(\"switch-user\")\n-                    previousUser = message.dataset.user_uid\n-                }else{\n-                    message.classList.remove(\"switch-user\")\n-                }\n-            })\n+    function updateLayout() {\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        messagesContainer.scrollTop = messagesContainer.scrollHeight + 1000;\n+\n+        updateTimes();\n+        let previousUser = null;\n+        document.querySelectorAll(\".message\").forEach((message) => {\n+            if (previousUser !== message.dataset.user_uid) {\n+                message.classList.add(\"switch-user\");\n+                previousUser = message.dataset.user_uid;\n+            } else {\n+                message.classList.remove(\"switch-user\");\n+            }\n+        });\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) return;\n+\n+        if (data.username !== \"{{ user.username.value }}\") {\n+            app.playSound(0);\n         }\n-         setInterval(() => {\n-                \n-            \n-                updateTimes()\n \n-            }, 1000)\n+        const message = document.createElement(\"div\");\n+        message.dataset.color = data.color;\n+        message.dataset.created_at = data.created_at;\n+        message.dataset.user_nick = data.user_nick;\n+        message.dataset.uid = data.uid;\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        updateLayout();\n+    });\n \n-        app.addEventListener(\"channel-message\", (data) => {\n-            if(data.channel_uid != channelUid) return\n-            if(data.username != \"{{user.username.value}}\"){\n-                app.playSound(0)\n-            }\n-            const message = document.createElement(\"div\")\n-            message.dataset.color = data.color\n-            message.dataset.created_at = data.created_at\n-            message.dataset.user_nick = data.user_nick\n-            message.dataset.uid = data.uid\n-            message.innerHTML = data.html\n-            document.querySelector(\".chat-messages\").appendChild(message.firstChild)\n-            setTimeout(()=>{\n-            updateLayout()\n-            },50)\n-        })\n-        updateLayout()\n-    </script>\n+    initInputField(document.querySelector(\"textarea\"));\n+    updateLayout();\n+</script>\n {% endblock %}\n+\n+"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved layout updates after message insertion", "commit": "bfca2bdf734c9b9522186c1ff1b6479f93f34658", "diff": "commit bfca2bdf734c9b9522186c1ff1b6479f93f34658\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:07:38 2025 +0100\n\n    So smooth.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 01c5506..8df9992 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -79,6 +79,9 @@\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout();\n+        setTimeout(()=>{\n+            updateLayout()\n+        },200)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor CSS for improved layout and styling", "commit": "83121f7fa99df690a3b9029556aa023226cf22ef", "diff": "commit 83121f7fa99df690a3b9029556aa023226cf22ef\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 13:19:54 2025 +0100\n\n    Updated style.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 46517cf..9cbd146 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,6 +1,5 @@\n * {\n   margin: 0;\n   box-sizing: border-box;\n }\n \n@@ -9,23 +8,14 @@\n   height: auto;\n   overflow: auto;\n   flex: 1;\n-  &.tile {\n+}\n+\n+.gallery.tile, .tile {\n   width: 100px;\n   height: 100px;\n   object-fit: cover;\n-  margin-right: 10px;\n+  margin: 20px 10px 20px 0;\n   border-radius: 5px;\n-  margin: 20px;\n-  }\n-}\n-.tile {\n-  \n-    width: 100px;\n-    height: 100px;\n-    object-fit: cover;\n-    margin-right: 10px;\n-    border-radius: 5px;\n-    margin: 20px;\n }\n \n body {\n@@ -38,8 +28,11 @@ body {\n   height: 100vh;\n   min-width: 100%;\n }\n+\n main {\n-  min-width: 100%;\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n }\n \n header {\n@@ -63,12 +56,13 @@ header nav a {\n   font-size: 1em;\n   transition: color 0.3s;\n }\n+\n .no-select {\n-    }\n+  -webkit-user-select: none;  \n+  -moz-user-select: none;     \n+  -ms-user-select: none;      \n+  user-select: none;          \n+}\n \n header nav a:hover {\n@@ -80,62 +74,6 @@ a {\n   margin-bottom: 3px;\n }\n \n-.chat-area ul {\n-    margin: 0px;\n-    padding: 0px;\n-li {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n-    list-style: none;\n-      padding: 0;\n-      margin: 0; \n-      font-size: 1.5em;\n-    a {\n-        text-decoration: none;\n-    }\n-  \n-}\n-}\n-main {\n-  display: flex;\n-  flex: 1;\n-  overflow: hidden;\n-}\n-\n-.sidebar {\n-  width: 250px;\n-  padding: 20px;\n-  overflow-y: auto;\n-}\n-\n-.sidebar h2 {\n-  font-size: 1.2em;\n-  margin-bottom: 20px;\n-}\n-\n-.sidebar ul {\n-  list-style: none;\n-}\n-\n-.sidebar ul li {\n-  margin-bottom: 15px;\n-}\n-\n-.sidebar ul li a {\n-  text-decoration: none;\n-  font-size: 1em;\n-  transition: color 0.3s;\n-}\n-\n-.sidebar ul li a:hover {\n-}\n-\n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -153,12 +91,14 @@ main {\n   font-size: 1.2em;\n }\n-message-list {\n-  flex: 1;;\n-   height: 200px;\n-   padding-bottom: 40px;\n+\n+.message-list {\n+  flex: 1;\n+  height: 200px;\n+  padding-bottom: 40px;\n   overflow-y: auto;\n }\n+\n .chat-messages {\n   flex: 1;\n   overflow-y: auto;\n@@ -167,20 +107,12 @@ message-list {\n }\n \n-.message-list-manager {\n-  flex: 1;\n-  overflow-y: auto;\n-}\n-\n .chat-messages .message {\n   display: flex;\n   align-items: flex-start;\n-  margin-bottom: 0px;\n+  margin-bottom: 0;\n   padding: 5px;\n-  \n   border-radius: 8px;\n }\n \n .chat-messages .message .avatar {\n@@ -196,6 +128,7 @@ message-list {\n   align-items: center;\n   margin-right: 15px;\n }\n+\n .chat-messages .message .message-content {\n   flex: 1;\n }\n@@ -211,20 +144,17 @@ message-list {\n   word-break: break-word;\n   overflow-wrap: break-word;\n-  display: block;\n-\n }\n+\n .message-content {\n   width: 100%;\n-    display:block;\n-    p {\n-        display: block;\n-        width:100%;\n-    }\n+  display: block;\n }\n+\n .message-content img {\n-   max-width: 100%; \n+  max-width: 100%; \n }\n+\n .chat-messages .message .message-content .time {\n   font-size: 0.8em;\n@@ -238,17 +168,7 @@ message-list {\n }\n \n-input[type=\"text\"] {\n-  flex: 1;\n-  color: white;\n-  border: none;\n-  padding: 10px;\n-  border-radius: 5px;\n-  resize: none;\n-}\n-\n-.chat-input textarea {\n+input[type=\"text\"], .chat-input textarea {\n   flex: 1;\n   color: white;\n@@ -278,89 +198,101 @@ input[type=\"text\"] {\n   .sidebar {\n     display: none;\n   }\n-\n-  .chat-area {\n-    flex: 1;\n-  }\n }\n \n .message {\n   .text {\n-  max-width: 100%;\n-  word-wrap: break-word;\n-  overflow-wrap: break-word;\n-  hyphens: auto;\n-    img {\n-      max-width: 90%;\n-      border-radius: 20px;\n-    }\n-    {\n-        padding: 0;\n-        margin: 0;\n-    }\n-  }\n-  .avatar {\n-      opacity: 0;\n+    max-width: 100%;\n+    word-wrap: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n   }\n \n-  .author {\n-      display: none;\n+  .avatar {\n+    opacity: 0;\n   }\n \n-  .time {\n-      display: none;\n+  .author, .time {\n+    display: none;\n   }\n }\n+\n .message.switch-user {\n-  .text {\n-  max-width: 100%;\n-  word-wrap: break-word;\n-  overflow-wrap: break-word;\n-  hyphens: auto;\n-    img{\n-      max-width: 90%;\n-      border-radius: 20px;\n-    }\n+  .text img {\n+    max-width: 90%;\n+    border-radius: 20px;\n   }\n+  \n   .avatar {\n-      opacity: 1;\n+    opacity: 1;\n   }\n+\n   .author {\n-      display: block;\n+    display: block;\n   }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child\n- {\n-    .time {\n-        display: block;\n-    }\n+.message:has(+ .message.switch-user), .message:last-child .time {\n+  display: block;\n }\n \n ::-webkit-scrollbar {\n+  width: 6px;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n+  border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n-.chat-messages{\n+.chat-messages {\n   scrollbar-width: thin;\n }\n+\n+a {\n+    text-decoration:none\n+}\n+.sidebar {\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n+}\n+\n+.sidebar h2 {\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n+}\n+\n+.sidebar ul {\n+  list-style: none;\n+}\n+\n+.sidebar ul li {\n+  margin-bottom: 15px;\n+}\n+\n+.sidebar ul li a {\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n+}\n+\n+.sidebar ul li a:hover {\n+}\n+"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Correctly append message element to chat messages", "commit": "dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797", "diff": "commit dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 14:58:26 2025 +0100\n\n    Updated web.html.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df9992..38d652e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -77,7 +77,7 @@\n         message.dataset.user_nick = data.user_nick;\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        document.querySelector(\".chat-messages\").appendChild(message);\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved text wrapping and code highlighting.", "commit": "cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd", "diff": "commit cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:46:02 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9cbd146..8389618 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -138,12 +138,24 @@ a {\n   margin-bottom: 3px;\n }\n-\n+* {\n+word-break: break-word;\n+  overflow-wrap: break-word;\n+    hyphens: auto;\n+}\n+.highlight pre {\n+    white-space: pre-wrap;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n+  }\n .chat-messages .message .message-content .text {\n   margin-bottom: 5px;\n   word-break: break-word;\n   overflow-wrap: break-word;\n+hyphens: auto;\n+ \n }\n \n .message-content {\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 8c43c49..2773f0a 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" data-message=\"{{message}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message appending to chat messages", "commit": "a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29", "diff": "commit a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:53:45 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 38d652e..8df9992 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -77,7 +77,7 @@\n         message.dataset.user_nick = data.user_nick;\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message);\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved message content styling and hyphenation", "commit": "661eba7161c1d869d73f04641878521fbdf8b72a", "diff": "commit 661eba7161c1d869d73f04641878521fbdf8b72a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:56:19 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 8389618..1aa4ba5 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -154,13 +154,12 @@ word-break: break-word;\n   word-break: break-word;\n   overflow-wrap: break-word;\n-hyphens: auto;\n- \n+hyphens: auto; \n }\n \n .message-content {\n-  width: 100%;\n   display: block;\n+    max-width: 100%;\n }\n \n .message-content img {"}
{"repo": ".", "date": "2025-02-09", "line": "style: Removed unnecessary block display from message content.", "commit": "e75836fe879e4f7e2a8bb34ed8ca901cc624ce05", "diff": "commit e75836fe879e4f7e2a8bb34ed8ca901cc624ce05\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 15:59:26 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 1aa4ba5..83378a0 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -158,7 +158,6 @@ hyphens: auto;\n }\n \n .message-content {\n-  display: block;\n     max-width: 100%;\n }"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message time display", "commit": "0f400a0b6aa04ffdfd8de1e26be3318a39a174a8", "diff": "commit 0f400a0b6aa04ffdfd8de1e26be3318a39a174a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 16:14:00 2025 +0100\n\n    Upgrade css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 83378a0..7483432 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -242,9 +242,11 @@ input[type=\"text\"], .chat-input textarea {\n   }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child .time {\n+.message:has(+ .message.switch-user), .message:last-child{ \n+    .time {\n   display: block;\n }\n+}\n \n ::-webkit-scrollbar {"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Enable CORS credentials", "commit": "49c0f932ab3e5705380a57cefa8da9ea7b9967d3", "diff": "commit 49c0f932ab3e5705380a57cefa8da9ea7b9967d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 9 23:49:54 2025 +0100\n\n    Updated cors.\n\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 69fe378..2946262 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -22,6 +22,7 @@ async def cors_allow_middleware(request, handler):\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response\n \n \n@@ -34,10 +35,12 @@ async def cors_middleware(request, handler):\n             \"GET, POST, PUT, DELETE, OPTIONS\"\n         )\n         response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+        response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n         return response\n \n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implemented online status and ping functionality", "commit": "54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1", "diff": "commit 54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:18:08 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex cac9aaf..54b6f30 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,3 +29,5 @@ class UserModel(BaseModel):\n         regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n     )\n     password = ModelField(name=\"password\", required=True, min_length=1)\n+\n+    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex ee23c2d..357c5dd 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,5 +1,7 @@\n from snek.system.service import BaseService\n+from datetime import datetime \n \n+from snek.system.model import now \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n@@ -29,6 +31,25 @@ class ChannelService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n+    async def get_users(self, channel_uid):\n+        users = []\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_uid,\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            yield await self.services.user.get(uid=channel_member[\"user_uid\"])\n+\n+    async def get_online_users(self, channel_uid):\n+        users = []\n+        async for user in self.get_users(channel_uid):\n+            if not user[\"last_ping\"]:\n+                continue\n+\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 30:\n+                yield user \n+\n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\n         is_moderator = False\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 1cad3ee..02707b0 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -27,6 +27,7 @@ class UserService(BaseService):\n             user['color'] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+\n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex a0f83c2..b6b468b 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -206,11 +206,14 @@ class Socket extends EventHandler {\n                 ws.onerror = () => {\n                     this.onClose();\n                 };\n+                this.onConnect()\n                 this.connectPromises.forEach(resolver => resolver(this));\n             };\n         });\n     }\n-\n+    onConnect(){\n+        this.emit(\"connected\")\n+    }\n     onData(data) {\n         if (data.success !== undefined && !data.success) {\n             console.error(data);\n@@ -282,12 +285,31 @@ class App extends EventHandler {\n     audio = null;\n     user = {};\n \n+    async ping(...args) {\n+        if(this.is_pinging)return false \n+        this.is_pinging = true\n+        await this.rpc.ping(...args);\n+        this.is_pinging = false\n+    }\n+    async forcePing(...arg) {\n+        await this.rpc.ping(...args);\n+    }\n+\n     constructor() {\n         super();\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n+        this.is_pinging = false \n+        this.ping_interval = setInterval(()=>{\n+            this.ping(\"active\")\n+        }, 15000)\n+        \n+\n         const me = this \n+        this.ws.addEventListener(\"connected\", (data)=> {\n+            this.ping(\"online\")\n+        })\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(\"channel-message\", data);\n         });\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 9aa89f5..02e7bff 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -11,6 +11,7 @@ from aiohttp import web\n from snek.system.view import BaseView\n import traceback\n import json\n+from snek.system.model import now \n \n class RPCView(BaseView):\n \n@@ -133,8 +134,24 @@ class RPCView(BaseView):\n \n         async def _send_json(self, obj):\n             await self.ws.send_str(json.dumps(obj, default=str))\n+    \n \n-        async def call_ping(self, callId, *args):\n+        async def get_online_users(self, channel_uid):\n+            self._require_login()\n+            \n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_online_users(channel_uid)]\n+\n+\n+        async def get_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_users(channel_uid)]\n+\n+        async def ping(self, callId, *args):\n+            if self.user_uid:\n+                user = await self.services.user.get(uid=self.user_uid)\n+                user['last_ping'] = now()\n+                await self.services.user.save(user)\n             return {\"pong\": args}\n \n     async def get(self):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Display online status for channel members", "commit": "688e7fbf0e8977f442edc41cea1ac2a06f1ece40", "diff": "commit 688e7fbf0e8977f442edc41cea1ac2a06f1ece40\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:22:34 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 357c5dd..72d9798 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -39,8 +39,9 @@ class ChannelService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            yield await self.services.user.get(uid=channel_member[\"user_uid\"])\n-\n+            user =  await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if user:\n+                yield user\n     async def get_online_users(self, channel_uid):\n         users = []\n         async for user in self.get_users(channel_uid):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Adjust online status check threshold", "commit": "48891c438694d37cd1b8338a2cc1f96f7647e77d", "diff": "commit 48891c438694d37cd1b8338a2cc1f96f7647e77d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:24:43 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 72d9798..567eba6 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -48,7 +48,7 @@ class ChannelService(BaseService):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 30:\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() >= 20:\n                 yield user \n \n     async def ensure_public_channel(self, created_by_uid):"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Include last ping in online user data", "commit": "087ab1a8a55ae58b52078dc5cb7de7db65132e84", "diff": "commit 087ab1a8a55ae58b52078dc5cb7de7db65132e84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:32:30 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 02e7bff..deb4286 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -139,13 +139,13 @@ class RPCView(BaseView):\n         async def get_online_users(self, channel_uid):\n             self._require_login()\n             \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_online_users(channel_uid)]\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n \n \n         async def get_users(self, channel_uid):\n             self._require_login()\n \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick']) async for record in self.services.channel.get_users(channel_uid)]\n+            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_users(channel_uid)]\n \n         async def ping(self, callId, *args):\n             if self.user_uid:"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implement online user status indicator", "commit": "3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493", "diff": "commit 3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 10 16:34:26 2025 +0100\n\n    Added online status\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 567eba6..e169d7c 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -48,7 +48,7 @@ class ChannelService(BaseService):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() >= 20:\n+            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n                 yield user \n \n     async def ensure_public_channel(self, created_by_uid):"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Add private chat functionality with DM support", "commit": "8a59ddd210bb3ab3d29f9207afbf887988b528d9", "diff": "commit 8a59ddd210bb3ab3d29f9207afbf887988b528d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:34:31 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex e169d7c..5291189 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -6,6 +6,27 @@ from snek.system.model import now\n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n+    async def get(\n+        self,\n+        uid=None,\n+        **kwargs):\n+        if uid:\n+            kwargs['uid'] = uid \n+            result = await super().get(**kwargs)\n+            if result:\n+                return result \n+            del kwargs['uid']\n+            kwargs['name'] = uid \n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            return None \n+        return await super().get(**kwargs)\n+\n     async def create(\n         self,\n         label,\n@@ -31,6 +52,20 @@ class ChannelService(BaseService):\n             return model\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n+    async def get_dm(self, user1, user2):\n+        channel_member = await self.services.channel_member.get_dm(\n+            user1, user2 \n+        )\n+        if channel_member:\n+            return await self.get(uid=channel_member[\"channel_uid\"])\n+        channel = await self.create(\n+            \"DM\", user1, tag=\"DM\"            \n+        )\n+        await self.services.channel_member.create_dm(\n+            channel[\"uid\"], user1, user2            \n+        )\n+        return channel \n+\n     async def get_users(self, channel_uid):\n         users = []\n         async for channel_member in self.services.channel_member.find(\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 6b9ce9e..f3ced31 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -16,7 +16,7 @@ class ChannelMemberService(BaseService):\n     ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n-            if model.is_banned.value:\n+            if model['is_banned']:\n                 return False\n             return model\n         model = await self.new()\n@@ -32,3 +32,17 @@ class ChannelMemberService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n+    \n+    async def get_dm(self,from_user, to_user):\n+        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+            return model\n+        return None \n+\n+    async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n+        result = await self.create(channel_uid, from_user_uid,tag=\"dm\")\n+        await self.create(channel_uid, to_user_uid,tag=\"dm\")\n+        return result \n+\n+\n+\n+\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex a088854..d65f947 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -40,7 +40,7 @@ class BaseService:\n         if uid:\n             if not kwargs:\n                 result = await self.cache.get(uid)\n-                if result:\n+                if False and result and result.__class__ == self.mapper.model_class:\n                     return result\n             kwargs[\"uid\"] = uid\n \ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex ba5b51b..8bce656 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -14,7 +14,7 @@\n         <ul>\n         {% for user in users %}\n     <li>\n-        <a href=\"/user/{{user.username.value}}\">{{user.username.value}}</a>\n+        <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n     </li>\n     \n     {% endfor %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8df9992..51f1117 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -15,12 +15,12 @@\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-        <upload-button channel=\"{{ channel.channel_uid.value }}\"></upload-button>\n+        <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n \n <script>\n-    const channelUid = \"{{ channel.channel_uid.value }}\";\n+    const channelUid = \"{{ channel.uid.value }}\";\n \n     function initInputField(textBox) {\n         textBox.addEventListener('change', (e) => {\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex e02d8b3..7eb4a9a 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -31,22 +31,44 @@ class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        channel = await self.services.channel.get(uid=self.request.match_info.get(\"channel\"))\n+        if not channel:\n+            user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+            if user:\n+                channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+        if not channel:\n+            return web.HTTPNotFound()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n+            channel[\"uid\"]\n+        )]\n+        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages})\n+\n+        \n+\n+\n+    async def get2(self):\n \n         if self.login_required and not self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/\")\n \n+        channel = None\n+\n         if not self.request.match_info.get(\"channel\"):\n             channel = await self.app.services.channel.get(\n                     tag=\"public\",deleted_at=None\n             )\n-            if not channel:\n-                return web.HTTPNotFound()\n-            return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        else:\n+            if channel:\n+                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        if not channel:\n             print(self.request.match_info.get(\"channel\"), flush=True)\n             channel = await self.app.services.channel.get(\n-                uid=str(self.request.match_info.get(\"channel\")),deleted_at=None\n+                uid=str(self.request.match_info.get(\"channel\"))\n             )\n+            if channel:\n+                print(f'found {channel[\"uid\"]} {channel[\"name\"]}', flush=True)\n             if not channel:\n \n@@ -56,16 +78,77 @@ class WebView(BaseView):\n                 channel = await self.app.services.channel.get(\n                     label=name,deleted_at=None\n                 )\n+            if not channel:\n+                user = await self.app.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+                if not user:\n+                    print(\"HIERRR EXIT\\n\",flush=True)\n+                    return web.HTTPNotFound()\n+                \n+                print(\"FOUND USer: \",user['username'],flush=True)\n+                own_channels = self.app.services.channel_member.find(\n+                    user_uid=self.session.get(\"uid\"),deleted_at=None\n+                )\n+                user_channels = self.app.services.channel_member.find(\n+                    user_uid=user['uid'],deleted_at=None\n+                )\n+                found_channel = False\n+                async for user_channel in user_channels:\n+                    if found_channel:\n+                        break\n+                    async for own_channel in own_channels:\n+                        if user_channel[\"channel_uid\"] == own_channel[\"channel_uid\"]:\n+                            channel = await self.app.services.channel.get(uid=user_channel[\"channel_uid\"])\n+                            if channel['tag'] == 'dm':\n+                                found_channel = True \n+                                print(\"FOUND DM\\n\",flush=True)\n+                                break\n+                            channel = None \n+                        else:\n+                            print(\"Channel mistmatch!\\n\",flush=True)\n+                if found_channel:\n+                    print(f\"FOUND CHANNEL; {channel['uid']}\\n\",flush=True)\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+ \n+                channel = await self.app.services.channel.create(\n+                    label=\"Direct Message\",\n+                    created_by_uid=self.session.get(\"uid\"),\n+                    tag=\"dm\",\n+                    description=\"Direct Chat\",\n+                    is_private=True,\n+                    is_listed=True\n+                )\n+                print(f\"UID NEW CHANNELr: {channel['uid']}\\n\",flush=True)\n+                channel_member_self = await self.app.services.channel_member.create(\n+                    channel_uid=channel['uid'],\n+                    user_uid=self.session.get(\"uid\"),\n+                    is_moderator=True\n+                )\n+                print(f\"UID NEW CHANNEL_MEMBER SELF: {channel_member_self['uid']}\\n\",flush=True)\n+                channel_member_user = await self.app.services.channel_member.create(\n+                    channel_uid=channel['uid'],\n+                    user_uid=user[\"uid\"],\n+                    is_moderator=True\n+                )     \n+                print(f\"UID NEW CHANNEL_MEMBER USER: {channel_member_user['uid']}\\n\",flush=True)\n+                self.app.db.commit()\n+                print(f\"REDIRECT NAAR GOEDE: {channel['uid']}\\n\",flush=True)\n+                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+            \n             if not channel:\n                 print(\"NOT found!\\n\",flush=True)\n                 return web.HTTPNotFound()\n+            print(channel['uid'],\":\",self.session.get('uid'),flush=True)\n+            from pprint import pprint as pp \n+            pp(channel)\n             channel_member = await self.app.services.channel_member.get(\n-                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None,is_banned=False\n+                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None\n             )\n             if not channel_member:\n+                print(\"NO CHANNEL_MEMBER\")\n                 return web.HTTPNotFound()\n            \n-        print(\"HIER\\n\",flush=True) \n+        print(\"HIER\\n\",flush=True)\n+        print(\"UUID=\",self.session.get(\"uid\"),flush=True)\n         user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n         if not user:\n             return web.HTTPNotFound()"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality", "commit": "ca463b79a88687a76d9fab851b8f2ffc7e071e81", "diff": "commit ca463b79a88687a76d9fab851b8f2ffc7e071e81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:38:05 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex f3ced31..075e49e 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -39,8 +39,8 @@ class ChannelMemberService(BaseService):\n         return None \n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n-        result = await self.create(channel_uid, from_user_uid,tag=\"dm\")\n-        await self.create(channel_uid, to_user_uid,tag=\"dm\")\n+        result = await self.create(channel_uid, from_user_uid)\n+        await self.create(channel_uid, to_user_uid)\n         return result"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and updated tag to lowercase", "commit": "8fe24f711cb3e796471145a086c4b72289e12e1a", "diff": "commit 8fe24f711cb3e796471145a086c4b72289e12e1a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:40:04 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 5291189..a1ff9e6 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -59,7 +59,7 @@ class ChannelService(BaseService):\n         if channel_member:\n             return await self.get(uid=channel_member[\"channel_uid\"])\n         channel = await self.create(\n-            \"DM\", user1, tag=\"DM\"            \n+            \"DM\", user1, tag=\"dm\"            \n         )\n         await self.services.channel_member.create_dm(\n             channel[\"uid\"], user1, user2"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat redirection", "commit": "bfe4b351c1fa750c9d12d3ae880928cca0346bba", "diff": "commit bfe4b351c1fa750c9d12d3ae880928cca0346bba\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:42:03 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 7eb4a9a..c1710d5 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -38,6 +38,8 @@ class WebView(BaseView):\n             user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n             if user:\n                 channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+                if channel:\n+                    return await web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "be35a6caf07c51eaf79625ad914215bebb9b11c5", "diff": "commit be35a6caf07c51eaf79625ad914215bebb9b11c5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 15:42:54 2025 +0100\n\n    Added private chat.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex c1710d5..8643b6d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -39,7 +39,7 @@ class WebView(BaseView):\n             if user:\n                 channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n                 if channel:\n-                    return await web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added base URL property and file type handling for uploads", "commit": "2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420", "diff": "commit 2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 19:30:55 2025 +0100\n\n    Iframe funcs.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 8c7537e..5371d33 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -12,6 +12,10 @@ class BaseView(web.View):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \n+    @property \n+    def base_url(self):\n+        return str(self.request.url.with_path('').with_query(''))\n+\n     @property\n     def app(self):\n         return self.request.app\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 2d7d98b..e8abc32 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -38,6 +38,17 @@ class UploadView(BaseView):\n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n         print(str(drive), flush=True)\n+        extension_types = {\n+            \".jpg\": \"image\",\n+            \".gif\": \"image\",\n+            \".png\": \"image\",\n+            \".jpeg\": \"image\",\n+            \".mp4\": \"video\",\n+            \".mp3\": \"audio\",\n+            \".pdf\": \"document\",\n+            \".doc\": \"document\",\n+            \".docx\": \"document\"\n+        }\n \n         while field := await reader.next():\n             if field.name == \"channel_uid\":\n@@ -58,10 +69,20 @@ class UploadView(BaseView):\n             drive_item = await self.services.drive_item.create(\n                 drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n             )\n+           \n+            type_ = \"unknown\"\n+            extension = \".\" + filename.split(\".\")[-1]\n+            if extension in extension_types:\n+                type_ = extension_types[extension] \n+            \n+            await self.services.drive_item.save(drive_item)\n+            response = \"<iframe width=\\\"100%\\\" frameborder=\\\"0\\\" allowfullscreen title=\\\"Embedded\\\" src=\\\"\" + self.base_url + \"/drive.bin/\" + drive_item[\"uid\"] + \"\\\"></iframe>\\n\"\n+            if type_ == \"image\":\n+                response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n \n             await self.services.chat.send(\n-                self.request.session.get(\"uid\"), channel_uid, f\"![{filename}](/drive.bin/{drive_item['uid']})\"\n+                self.request.session.get(\"uid\"), channel_uid, response\n             )\n             print(drive_item, flush=True)\n \n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n\\ No newline at end of file\n+        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Display uploaded files as links instead of iframes", "commit": "2541fc536aa45630ec55297c58787686e4154fab", "diff": "commit 2541fc536aa45630ec55297c58787686e4154fab\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 19:34:47 2025 +0100\n\n    Iframe funcs.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex e8abc32..8f13420 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -76,7 +76,8 @@ class UploadView(BaseView):\n                 type_ = extension_types[extension] \n             \n             await self.services.drive_item.save(drive_item)\n-            response = \"<iframe width=\\\"100%\\\" frameborder=\\\"0\\\" allowfullscreen title=\\\"Embedded\\\" src=\\\"\" + self.base_url + \"/drive.bin/\" + drive_item[\"uid\"] + \"\\\"></iframe>\\n\"\n+            response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n             if type_ == \"image\":\n                 response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\""}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added echo endpoint and noresponse return value", "commit": "b6eba608435be4d798e50328f9149a8768a5cc8e", "diff": "commit b6eba608435be4d798e50328f9149a8768a5cc8e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 11 21:11:21 2025 +0100\n\n    Echo service.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex deb4286..b164c2c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -127,8 +127,9 @@ class RPCView(BaseView):\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n-                    success = False \n-                await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n+                    success = False\n+                if result != \"noresponse\":\n+                    await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n@@ -141,6 +142,9 @@ class RPCView(BaseView):\n             \n             return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n \n+        async def echo(self, obj):\n+            await self.ws.send_json(obj)\n+            return \"noresponse\"\n \n         async def get_users(self, channel_uid):\n             self._require_login()"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and updated templates", "commit": "3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0", "diff": "commit 3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 13 19:47:05 2025 +0100\n\n    Added channel list.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex a1ff9e6..95ceef7 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -86,6 +86,15 @@ class ChannelService(BaseService):\n             if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n                 yield user \n \n+    async def get_for_user(self, user_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            user_uid=user_uid,\n+            is_banned=False,\n+            deleted_at=None,\n+        ):\n+            channel = await self.get(uid=channel_member[\"channel_uid\"])\n+            yield channel \n+\n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\n         is_moderator = False\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 07fda6d..c50ff60 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -33,11 +33,9 @@\n     <aside class=\"sidebar\">\n       <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n-       \n+        {% for channel in channels %}\n+        <li><a class=\"no-select\" href=\"/web/{{channel['channel_uid']}}.html\">{{channel['label']}}</a></li>\n+        {% endfor %}\n       </ul>\n     </aside>\n     {% endblock %}\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 8643b6d..412ba7d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,121 +46,8 @@ class WebView(BaseView):\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n         )]\n-        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages})\n-\n-        \n-\n-\n-    async def get2(self):\n-\n-        if self.login_required and not self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/\")\n-\n-        channel = None\n-\n-        if not self.request.match_info.get(\"channel\"):\n-            channel = await self.app.services.channel.get(\n-                    tag=\"public\",deleted_at=None\n-            )\n-            if channel:\n-                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        if not channel:\n-            print(self.request.match_info.get(\"channel\"), flush=True)\n-            channel = await self.app.services.channel.get(\n-                uid=str(self.request.match_info.get(\"channel\"))\n-            )\n-            if channel:\n-                print(f'found {channel[\"uid\"]} {channel[\"name\"]}', flush=True)\n-            if not channel:\n-\n-                \n-                print(\"TADAAA:\",name, flush=True)\n-\n-                channel = await self.app.services.channel.get(\n-                    label=name,deleted_at=None\n-                )\n-            if not channel:\n-                user = await self.app.services.user.get(uid=self.request.match_info.get(\"channel\"))\n-                if not user:\n-                    print(\"HIERRR EXIT\\n\",flush=True)\n-                    return web.HTTPNotFound()\n-                \n-                print(\"FOUND USer: \",user['username'],flush=True)\n-                own_channels = self.app.services.channel_member.find(\n-                    user_uid=self.session.get(\"uid\"),deleted_at=None\n-                )\n-                user_channels = self.app.services.channel_member.find(\n-                    user_uid=user['uid'],deleted_at=None\n-                )\n-                found_channel = False\n-                async for user_channel in user_channels:\n-                    if found_channel:\n-                        break\n-                    async for own_channel in own_channels:\n-                        if user_channel[\"channel_uid\"] == own_channel[\"channel_uid\"]:\n-                            channel = await self.app.services.channel.get(uid=user_channel[\"channel_uid\"])\n-                            if channel['tag'] == 'dm':\n-                                found_channel = True \n-                                print(\"FOUND DM\\n\",flush=True)\n-                                break\n-                            channel = None \n-                        else:\n-                            print(\"Channel mistmatch!\\n\",flush=True)\n-                if found_channel:\n-                    print(f\"FOUND CHANNEL; {channel['uid']}\\n\",flush=True)\n-                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n- \n-                channel = await self.app.services.channel.create(\n-                    label=\"Direct Message\",\n-                    created_by_uid=self.session.get(\"uid\"),\n-                    tag=\"dm\",\n-                    description=\"Direct Chat\",\n-                    is_private=True,\n-                    is_listed=True\n-                )\n-                print(f\"UID NEW CHANNELr: {channel['uid']}\\n\",flush=True)\n-                channel_member_self = await self.app.services.channel_member.create(\n-                    channel_uid=channel['uid'],\n-                    user_uid=self.session.get(\"uid\"),\n-                    is_moderator=True\n-                )\n-                print(f\"UID NEW CHANNEL_MEMBER SELF: {channel_member_self['uid']}\\n\",flush=True)\n-                channel_member_user = await self.app.services.channel_member.create(\n-                    channel_uid=channel['uid'],\n-                    user_uid=user[\"uid\"],\n-                    is_moderator=True\n-                )     \n-                print(f\"UID NEW CHANNEL_MEMBER USER: {channel_member_user['uid']}\\n\",flush=True)\n-                self.app.db.commit()\n-                print(f\"REDIRECT NAAR GOEDE: {channel['uid']}\\n\",flush=True)\n-                return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-            \n-            if not channel:\n-                print(\"NOT found!\\n\",flush=True)\n-                return web.HTTPNotFound()\n-            print(channel['uid'],\":\",self.session.get('uid'),flush=True)\n-            from pprint import pprint as pp \n-            pp(channel)\n-            channel_member = await self.app.services.channel_member.get(\n-                channel_uid=channel[\"uid\"], user_uid=self.session.get(\"uid\"),deleted_at=None\n-            )\n-            if not channel_member:\n-                print(\"NO CHANNEL_MEMBER\")\n-                return web.HTTPNotFound()\n-           \n-        print(\"HIER\\n\",flush=True)\n-        print(\"UUID=\",self.session.get(\"uid\"),flush=True)\n-        user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        if not user:\n-            return web.HTTPNotFound()\n-\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-\n-        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n-            channel[\"uid\"]\n-        )]\n-        \n-\n-        return await self.render_template(\"web.html\", {\"channel\": channel_member,\"user\": user,\"messages\": messages})\n+        channels = []\n+        async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+            print(\"CHANNELL!!\\n\",flush=True)\n+            channels.append(subscribed_channel)\n+        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and updated routing to channel UIDs", "commit": "37da903936e4ab85fae254421c356966991d53e4", "diff": "commit 37da903936e4ab85fae254421c356966991d53e4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 13 20:21:34 2025 +0100\n\n    Added channel list.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 075e49e..407d0b7 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -37,6 +37,19 @@ class ChannelMemberService(BaseService):\n         async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n             return model\n         return None \n+    \n+    async def get_other_dm_user(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n+        if channel[\"tag\"] != \"dm\":\n+            print(\"NONT!\\n\", flush=True)\n+            return None\n+        print(\"YEAHH\",flush=True)\n+        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n+            print(\"huh!!!\",model['uid'],flush=True)\n+            if model[\"uid\"] != channel_member['uid']:\n+                print(\"GOOOD!!\",flush=True)\n+                return await self.services.user.get(uid=model[\"user_uid\"])\n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n         result = await self.create(channel_uid, from_user_uid)\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 309e959..7607f03 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -54,8 +54,10 @@ class ChannelMessageService(BaseService):\n \n     async def offset(self, channel_uid, offset=0):\n         results = []\n-\n-        async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n-            results.append(model)\n+        try:\n+            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+                results.append(model)\n+        except: \n+            pass\n         results.sort(key=lambda x: x['created_at'])\n         return results \ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex c50ff60..8caaef8 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -34,7 +34,7 @@\n       <h2 class=\"no-select\">Chat Rooms</h2>\n       <ul>\n         {% for channel in channels %}\n-        <li><a class=\"no-select\" href=\"/web/{{channel['channel_uid']}}.html\">{{channel['label']}}</a></li>\n+        <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\n         {% endfor %}\n       </ul>\n     </aside>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 51f1117..1430a04 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -48,8 +48,8 @@\n \n     function updateLayout() {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        messagesContainer.scrollTop = messagesContainer.scrollHeight + 1000;\n-\n+        \n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -60,6 +60,9 @@\n                 message.classList.remove(\"switch-user\");\n             }\n         });\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        lastMessage.scrollIntoView({ inline: \"nearest\" });\n+\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -81,7 +84,7 @@\n         updateLayout();\n         setTimeout(()=>{\n             updateLayout()\n-        },200)\n+        },1000)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 412ba7d..dda589f 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -48,6 +48,13 @@ class WebView(BaseView):\n         )]\n         channels = []\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-            print(\"CHANNELL!!\\n\",flush=True)\n-            channels.append(subscribed_channel)\n+            item = {}\n+            other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n+            if other_user:\n+                item[\"name\"] = other_user[\"nick\"]\n+                item[\"uid\"] = other_user[\"uid\"]\n+            else:\n+                item[\"name\"] = subscribed_channel[\"label\"]\n+                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+            channels.append(item)\n         return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Refactor socket communication and remove unnecessary prints.\n\nfix: Corrected pagination offset in channel message retrieval.", "commit": "1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb", "diff": "commit 1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 21:07:02 2025 +0100\n\n    Update socket communicaton and removed prints.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 407d0b7..f34f08b 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -28,7 +28,6 @@ class ChannelMemberService(BaseService):\n         model[\"is_read_only\"] = is_read_only\n         model[\"is_muted\"] = is_muted\n         model[\"is_banned\"] = is_banned\n-        print(model.record, flush=True)\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n@@ -42,13 +41,9 @@ class ChannelMemberService(BaseService):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n         if channel[\"tag\"] != \"dm\":\n-            print(\"NONT!\\n\", flush=True)\n             return None\n-        print(\"YEAHH\",flush=True)\n         async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            print(\"huh!!!\",model['uid'],flush=True)\n             if model[\"uid\"] != channel_member['uid']:\n-                print(\"GOOOD!!\",flush=True)\n                 return await self.services.user.get(uid=model[\"user_uid\"])\n \n     async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 7607f03..c421b5e 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -38,7 +38,6 @@ class ChannelMessageService(BaseService):\n     async def to_extended_dict(self, message):\n         user = await self.services.user.get(uid=message[\"user_uid\"])\n         if not user:\n-            print(\"User not found!\", flush=True)\n             return {}\n         return {\n             \"uid\": message[\"uid\"],\n@@ -52,10 +51,11 @@ class ChannelMessageService(BaseService):\n             \"username\": user['username'] \n         }\n \n-    async def offset(self, channel_uid, offset=0):\n+    async def offset(self, channel_uid, page=0, page_size=30):\n         results = []\n+        offset = page * page_size \n         try:\n-            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 60 OFFSET :offset\",dict(channel_uid=channel_uid, offset=offset)):\n+            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n                 results.append(model)\n         except: \n             pass\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex b058044..a0f1e9b 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,4 +1,4 @@\n-\n+from snek.model.user import UserModel\n \n \n from snek.system.service import BaseService\n@@ -6,35 +6,58 @@ from snek.system.service import BaseService\n \n class SocketService(BaseService):\n \n+    class Socket:\n+        def __init__(self, ws, user: UserModel):\n+            self.ws = ws\n+            self.is_connected = True \n+            self.user = user \n+\n+        async def send_json(self, data):\n+            if not self.is_connected:\n+                return False \n+            try:\n+                await self.ws.send_json(data)\n+            except Exception as ex:\n+                print(ex,flush=True)\n+                self.is_connected = False\n+            return True \n+\n+        async def close(self):\n+            if not self.is_connected:\n+                return True \n+            \n+            await self.ws.close()\n+            self.is_connected = False\n+            \n+            return True  \n+\n+\n     def __init__(self, app):\n         super().__init__(app)\n-        self.sockets = set()\n+        self.sockets = []\n         self.subscriptions = {}\n \n-    async def add(self, ws):\n-        self.sockets.add(ws)\n+    async def add(self, ws, user_uid):\n+        self.sockets.append(self.Socket(ws, await self.app.services.user.get(uid=user_uid)))\n \n-    async def subscribe(self, ws, channel_uid):\n+    async def subscribe(self, ws,channel_uid, user_uid):\n         if not channel_uid in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n-        self.subscriptions[channel_uid].add(ws)\n+        s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n+        self.subscriptions[channel_uid].add(s)\n \n     async def broadcast(self, channel_uid, message):\n-        print(\"BROADCAT!\",message)\n         count = 0\n         subscriptions = set(self.subscriptions.get(channel_uid,[]))\n-        for ws in subscriptions:\n-            try:\n-                await ws.send_json(message)\n-            except Exception as ex:\n-                print(ex,flush=True)\n-                print(\"Deleting socket.\",flush=True)\n-                self.subscriptions[channel_uid].remove(ws)\n+        for s in subscriptions:\n+            if not await s.send_json(message):\n+                self.subscriptions[channel_uid].remove(s)\n                 continue \n             count += 1\n         return count\n     async def delete(self, ws):\n-        try:\n-            self.sockets.remove(ws) \n-        except :\n-            pass \n+        for s in self.sockets:\n+            if s.ws == ws:\n+                await s.close()\n+                self.sockets.remove(s)\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex cd8a9b1..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -86,18 +86,17 @@ async def repair_links(base_url, html_content):\n \n \n async def is_html_content(content: bytes):\n+    if not content:\n+        return False\n     try:\n         content = content.decode(errors=\"ignore\")\n     except:\n         pass\n     marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n-    try:\n-        content = content.lower()\n-        for mark in marks:\n-            if mark in content:\n-                return True\n-    except Exception as ex:\n-        print(ex)\n+    content = content.lower()\n+    for mark in marks:\n+        if mark in content:\n+            return True\n     return False\n \n \ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b164c2c..f05f4a5 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -50,9 +50,9 @@ class RPCView(BaseView):\n             record = user.record\n             del record['password']\n             del record['deleted_at']\n-            await self.services.socket.add(self.ws)\n+            await self.services.socket.add(self.ws,self.view.request.session.get('uid'))\n             async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"])\n+                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"], self.view.request.session.get(\"uid\"))\n             return record \n \n         async def search_user(self, query): \n@@ -74,9 +74,7 @@ class RPCView(BaseView):\n         async def get_messages(self, channel_uid, offset=0):\n             self._require_login()\n             messages = []\n-            print(\"Channel uid:\", channel_uid, flush=True)\n             for message in await self.services.channel_message.offset(channel_uid, offset):\n-                print(message, flush=True)\n                 extended_dict = await self.services.channel_message.to_extended_dict(message)\n                 messages.append(extended_dict)\n             return messages\n@@ -162,9 +160,9 @@ class RPCView(BaseView):\n         ws = web.WebSocketResponse()\n         await ws.prepare(self.request)\n         if self.request.session.get(\"logged_in\"):\n-            await self.services.socket.add(ws)\n+            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n             async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"])\n+                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\"))\n         rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 04e9080..c28b883 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -41,7 +41,6 @@ class SearchUserView(BaseFormView):\n         query = self.request.query.get(\"query\")\n         if query:\n             users = await self.app.services.user.search(query)\n-            print(users, flush=True)\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n@@ -50,6 +49,5 @@ class SearchUserView(BaseFormView):\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            print(\"YES\\n\")\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n         return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 8f13420..d665e58 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -23,8 +23,6 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-\n-        print(await drive_item.to_json(), flush=True)\n         return web.FileResponse(drive_item[\"path\"])\n \n     async def post(self):\n@@ -37,7 +35,6 @@ class UploadView(BaseView):\n \n         drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n \n-        print(str(drive), flush=True)\n         extension_types = {\n             \".jpg\": \"image\",\n             \".gif\": \"image\",\n@@ -84,6 +81,5 @@ class UploadView(BaseView):\n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response\n             )\n-            print(drive_item, flush=True)\n \n         return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Improved socket management and messaging for enhanced user experience", "commit": "53be4b060a1fff9cf58c7224dc4522bb0cafa852", "diff": "commit 53be4b060a1fff9cf58c7224dc4522bb0cafa852\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 21:56:26 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a0f1e9b..0b6071d 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -34,30 +34,40 @@ class SocketService(BaseService):\n \n     def __init__(self, app):\n         super().__init__(app)\n-        self.sockets = []\n+        self.sockets = set()\n+        self.users = {}\n         self.subscriptions = {}\n \n     async def add(self, ws, user_uid):\n-        self.sockets.append(self.Socket(ws, await self.app.services.user.get(uid=user_uid)))\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.sockets.add(s)\n+        if not self.users.get(user_uid):\n+            self.users[user_uid] = set() \n+        self.users[user_uid].add(s)\n \n     async def subscribe(self, ws,channel_uid, user_uid):\n+        return\n         if not channel_uid in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n         s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n         self.subscriptions[channel_uid].add(s)\n \n+    async def send_to_user(self, user_uid, message):\n+        count = 0 \n+        for s in self.users.get(user_uid,[]):\n+            if await s.send_json(message):\n+               count += 1 \n+        return count  \n+\n     async def broadcast(self, channel_uid, message):\n         count = 0\n-        subscriptions = set(self.subscriptions.get(channel_uid,[]))\n-        for s in subscriptions:\n-            if not await s.send_json(message):\n-                self.subscriptions[channel_uid].remove(s)\n-                continue \n-            count += 1\n+        async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n+            count += await self.send_to_user(channel_member[\"user_uid\"],message)\n         return count\n+    \n     async def delete(self, ws):\n-        for s in self.sockets:\n-            if s.ws == ws:\n-                await s.close()\n-                self.sockets.remove(s)\n-        \n\\ No newline at end of file\n+        for s in [sock for sock in self.sockets if sock.ws == ws]:\n+            await s.close()\n+            self.sockets.remove(s)\n+            \n+    \n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 39e6fc3..f9c4761 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(\"Cache store! New version:\", self.version, flush=True)\n+            print(f\"Cache store! {len(self.lru)} items. New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex f05f4a5..d664435 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -91,9 +91,9 @@ class RPCView(BaseView):\n                 })\n             return channels\n \n-        async def send_message(self, room, message):\n+        async def send_message(self, channel_uid, message):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid, room, message)\n+            await self.services.chat.send(self.user_uid, channel_uid, message)\n             return True \n \n         async def echo(self, *args):"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Add channel tag to RPC view", "commit": "9c3abdec2613c4d492c363cca8a07882dd3d8135", "diff": "commit 9c3abdec2613c4d492c363cca8a07882dd3d8135\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 23:01:43 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d664435..901ee14 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -83,9 +83,11 @@ class RPCView(BaseView):\n             self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n+                channel = await self.services.channel.get(uid=subscription['uid'])\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n+                    \"tag\": channel[\"tag\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n                     \"is_read_only\": subscription[\"is_read_only\"]\n                 })"}
{"repo": ".", "date": "2025-02-15", "line": "fix: Correct channel UID retrieval in RPCView", "commit": "d1396801c05688e15ad7f1082dab2576b9a2b011", "diff": "commit d1396801c05688e15ad7f1082dab2576b9a2b011\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 15 23:03:45 2025 +0100\n\n    Spread to new users.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 901ee14..06d903c 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -83,7 +83,7 @@ class RPCView(BaseView):\n             self._require_login()\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n-                channel = await self.services.channel.get(uid=subscription['uid'])\n+                channel = await self.services.channel.get(uid=subscription['channel_uid'])\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed YouTube videos directly into links", "commit": "7c4334fe7b5b7e6ba44627a4f084638af51dd44c", "diff": "commit 7c4334fe7b5b7e6ba44627a4f084638af51dd44c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:23:11 2025 +0100\n\n    Updated video player.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex a543dd7..e8935b7 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -17,6 +17,10 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n+            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed media, images, and YouTube videos in links", "commit": "c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2", "diff": "commit c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:41:33 2025 +0100\n\n    Updated video player.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex e8935b7..60b176d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -23,7 +23,35 @@ def set_link_target_blank(text):\n             element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)\n-    \n+\n+def embed_youtube(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+def embed_image(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+        for extension in [\".png\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n+            if extension in element.attrs['href'].lower():\n+                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+def embed_media(text):\n+    soup = BeautifulSoup(text, 'html.parser') \n+    for element in soup.find_all(\"a\"):  \n+        for extension in [\".mp4\", \".mp3\", \".wav\", \".ogg\", \".webm\", \".flac\", \".aac\",\".mpg\",\".avi\",\".wmv\"]:\n+            if extension in element.attrs['href'].lower():\n+                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n+                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+    return str(soup)\n+\n+\n \n def linkify_https(text):\n@@ -83,7 +111,11 @@ class LinkifyExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return linkify_https(caller())\n+        result =  linkify_https(caller())\n+        result = embed_media(result)\n+        result = embed_image(result)\n+        result = embed_youtube(result)\n+        return result\n \n class PythonExtension(Extension):\n     tags = {\"py3\"}"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Added linkify_https to template rendering", "commit": "263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc", "diff": "commit 263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:49:37 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 60b176d..a17ee71 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -111,7 +111,7 @@ class LinkifyExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        result =  linkify_https(caller())\n+        result = linkify_https(caller())\n         result = embed_media(result)\n         result = embed_image(result)\n         result = embed_youtube(result)"}
{"repo": ".", "date": "2025-02-16", "line": "refactor: Remove unused YouTube embed code", "commit": "7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3", "diff": "commit 7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 22:53:28 2025 +0100\n\n    Progress.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex a17ee71..6410a9f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -17,10 +17,10 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n-            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n \n     return str(soup)"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Handle file extensions in upload URLs", "commit": "be956a13db0941008802701196ca5e3870ebf2aa", "diff": "commit be956a13db0941008802701196ca5e3870ebf2aa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Feb 16 23:05:56 2025 +0100\n\n    Changes\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3020584..d77606f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -85,7 +85,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n-        self.router.add_view(\"/drive.bin/{uid}\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n         self.router.add_view(\"/search-user.json\", SearchUserView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex d665e58..05ffcaa 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -75,8 +75,7 @@ class UploadView(BaseView):\n             await self.services.drive_item.save(drive_item)\n             response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            if type_ == \"image\":\n-                response = \"![image](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            response = \"[url](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Ensure images, videos, and iframes within messages are responsive", "commit": "ea4196af8f7d7e0c97c004a07817bdc1dac999f5", "diff": "commit ea4196af8f7d7e0c97c004a07817bdc1dac999f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 04:49:09 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7483432..a21ad41 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -161,8 +161,11 @@ hyphens: auto;\n     max-width: 100%;\n }\n \n-.message-content img {\n-  max-width: 100%; \n+.message-content {\n+\n+    img, video, iframe {\n+        max-width: 100%; \n+    }\n }\n \n .chat-messages .message .message-content .time {"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improved media handling in message content", "commit": "f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f", "diff": "commit f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 04:51:21 2025 +0100\n\n    Fix.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex a21ad41..83ae235 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -163,7 +163,7 @@ hyphens: auto;\n \n .message-content {\n \n-    img, video, iframe {\n+    img, video, iframe, div {\n         max-width: 100%; \n     }\n }\n@@ -231,7 +231,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .message.switch-user {\n-  .text img {\n+  .text img,iframe, video {\n     max-width: 90%;\n     border-radius: 20px;\n   }"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement scroll to bottom on new message", "commit": "2e69ac5921c16ea0cda1a1b7c84dd63ff458db62", "diff": "commit 2e69ac5921c16ea0cda1a1b7c84dd63ff458db62\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 10:51:26 2025 +0100\n\n    Added scroll only when has reached bottom.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1430a04..ee67a7c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -45,11 +45,18 @@\n             time.innerText = app.timeDescription(time.dataset.created_at);\n         });\n     }\n-\n-    function updateLayout() {\n+    function isElementVisible(element) {\n+        const rect = element.getBoundingClientRect();\n+        return (\n+            rect.top >= 0 &&\n+            rect.left >= 0 &&\n+            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+        );\n+    }\n+    function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        \n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -60,8 +67,10 @@\n                 message.classList.remove(\"switch-user\");\n             }\n         });\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        if(doScrollDown){ \n+            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        }\n \n     }\n \n@@ -73,6 +82,11 @@\n         if (data.username !== \"{{ user.username.value }}\") {\n             app.playSound(0);\n         }\n+        \n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n+        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+\n \n         const message = document.createElement(\"div\");\n         message.dataset.color = data.color;\n@@ -81,14 +95,14 @@\n         message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n-        updateLayout();\n+        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n         setTimeout(()=>{\n-            updateLayout()\n+            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n         },1000)\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\n-    updateLayout();\n+    updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement timestamped pagination and focus textbox.", "commit": "8c33bc63d6cc623d0782f14c96a42c612accbc75", "diff": "commit 8c33bc63d6cc623d0782f14c96a42c612accbc75\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 13:37:05 2025 +0100\n\n    Focus textbox. Updated pagination.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex c421b5e..953881e 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -51,12 +51,20 @@ class ChannelMessageService(BaseService):\n             \"username\": user['username'] \n         }\n \n-    async def offset(self, channel_uid, page=0, page_size=30):\n+    async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):\n         results = []\n         offset = page * page_size \n         try:\n-            async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n-                results.append(model)\n+            if not timestamp:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+                    results.append(model)\n+            elif page >= 0:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at > :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                    results.append(model)\n+            else: \n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                    results.append(model)\n+\n         except: \n             pass\n         results.sort(key=lambda x: x['created_at'])\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ee67a7c..34972e7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -38,6 +38,7 @@\n                 }\n             }\n         });\n+        textBox.focus();\n     }\n \n     function updateTimes() {\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 06d903c..07893e1 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -71,10 +71,10 @@ class RPCView(BaseView):\n                 del record['email']\n             return record \n \n-        async def get_messages(self, channel_uid, offset=0):\n+        async def get_messages(self, channel_uid, offset=0,timestamp = None):\n             self._require_login()\n             messages = []\n-            for message in await self.services.channel_message.offset(channel_uid, offset):\n+            for message in await self.services.channel_message.offset(channel_uid, offset or 0,timestamp or None):\n                 extended_dict = await self.services.channel_message.to_extended_dict(message)\n                 messages.append(extended_dict)\n             return messages"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "477ca5917a59d3720a6a5ae01b307dec1a74cfaf", "diff": "commit 477ca5917a59d3720a6a5ae01b307dec1a74cfaf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:42:59 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 953881e..9683631 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -55,14 +55,14 @@ class ChannelMessageService(BaseService):\n         results = []\n         offset = page * page_size \n         try:\n-            if not timestamp:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+            if timestamp:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=timestamp)):\n                     results.append(model)\n-            elif page >= 0:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at > :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+            elif page > 0:\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n                     results.append(model)\n             else: \n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n                     results.append(model)\n \n         except: \ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 34972e7..1415497 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -55,6 +55,32 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n+\n+    const messagesContainer = document.querySelector(\".chat-messages\");\n+    async function loadExtra() {\n+    \n+        const fourthMessage = messagesContainer.querySelector(\".chat-messages :nth-child(4)\");\n+        if(!fourthMessage){\n+            return\n+        }\n+        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n+        if(fourthMessage.dataset.seen){\n+            return \n+        }\n+\n+        if(isElementVisible(fourthMessage)){\n+            fourthMessage.dataset.seen = true\n+            console.info(channelUid, fourthMessage.dataset.created_at)\n+            const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n+            messages.forEach((message) => {\n+                firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+            })\n+            console.info(messages)\n+        }\n+    }\n+    messagesContainer.addEventListener(\"scroll\",()=>{\n+        loadExtra()\n+    });\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "9e3b9ae326b6ec632f3280018ea68d1896645b9a", "diff": "commit 9e3b9ae326b6ec632f3280018ea68d1896645b9a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:50:20 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1415497..8a06293 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,23 +59,21 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const fourthMessage = messagesContainer.querySelector(\".chat-messages :nth-child(4)\");\n-        if(!fourthMessage){\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(15)\");\n+        if(!offsetMessage){\n             return\n         }\n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(fourthMessage.dataset.seen){\n+        if(offsetMessage.dataset.seen){\n             return \n         }\n \n-        if(isElementVisible(fourthMessage)){\n-            fourthMessage.dataset.seen = true\n-            console.info(channelUid, fourthMessage.dataset.created_at)\n+        if(isElementVisible(offsetMessage)){\n+            offsetMessage.dataset.seen = true\n             const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n-            console.info(messages)\n         }\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use offsetMessage timestamp for infinite scroll", "commit": "33bc695cda6bf5889d802129117ed59992b87143", "diff": "commit 33bc695cda6bf5889d802129117ed59992b87143\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:52:19 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8a06293..10581cc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -70,7 +70,7 @@\n \n         if(isElementVisible(offsetMessage)){\n             offsetMessage.dataset.seen = true\n-            const messages = await app.rpc.get_messages(channelUid, 1, fourthMessage.dataset.created_at);\n+            const messages = await app.rpc.get_messages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected method name for fetching messages", "commit": "aa5703e62f891fa7db09f07e6ce060875f5990d3", "diff": "commit aa5703e62f891fa7db09f07e6ce060875f5990d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:52:30 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 10581cc..01a972e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -70,7 +70,7 @@\n \n         if(isElementVisible(offsetMessage)){\n             offsetMessage.dataset.seen = true\n-            const messages = await app.rpc.get_messages(channelUid, 1, offsetMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected offset calculation for infinite scroll", "commit": "1792686531fb440d9f453422bfa648c890c255d1", "diff": "commit 1792686531fb440d9f453422bfa648c890c255d1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:54:25 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 01a972e..6fc488a 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,7 +59,7 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(15)\");\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(1)\");\n         if(!offsetMessage){\n             return\n         }"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust offset for infinite scroll", "commit": "3ee7c6d8024245933569fdaf3f99a71afd14fe8f", "diff": "commit 3ee7c6d8024245933569fdaf3f99a71afd14fe8f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 18:56:35 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 6fc488a..cf7b7cb 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -59,7 +59,7 @@\n     const messagesContainer = document.querySelector(\".chat-messages\");\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(1)\");\n+        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(10)\");\n         if(!offsetMessage){\n             return\n         }"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "95a8a458420dd2ebdbd6f7c03bd58c649985933e", "diff": "commit 95a8a458420dd2ebdbd6f7c03bd58c649985933e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:03:40 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex cf7b7cb..7740191 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -55,26 +55,41 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n+    \n \n     const messagesContainer = document.querySelector(\".chat-messages\");\n+    function isScrolledPastHalf() {\n+        let scrollTop = messagesContainer.scrollTop;\n+        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n+\n+        if (scrollTop < scrollableHeight / 2) {\n+            return false;\n+        }\n+        return true;\n+    }\n+    let isLoadingExtra = false;\n     async function loadExtra() {\n     \n-        const offsetMessage = messagesContainer.querySelector(\".chat-messages :nth-child(10)\");\n-        if(!offsetMessage){\n-            return\n-        }\n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(offsetMessage.dataset.seen){\n+        if(isLoadingExtra){\n             return \n         }\n-\n-        if(isElementVisible(offsetMessage)){\n-            offsetMessage.dataset.seen = true\n+        if(isScrolledPastHalf()){\n+            isLoadingExtra = true\n+        \n+        }\n+            \n             const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-            })\n-        }\n+        isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "162f89f9d0f7e304d355dd8f626ce2430dd840bf", "diff": "commit 162f89f9d0f7e304d355dd8f626ce2430dd840bf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:04:59 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 7740191..c4e6e13 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -89,7 +89,8 @@\n             const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-        isLoadingExtra = false;\n+            })\n+            isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use first message timestamp for infinite scroll", "commit": "104ee277669ee2cd55eb97b674f7a2432d31bb5a", "diff": "commit 104ee277669ee2cd55eb97b674f7a2432d31bb5a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:06:10 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex c4e6e13..3398957 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -86,7 +86,7 @@\n         \n         }\n             \n-            const messages = await app.rpc.getMessages(channelUid, 1, offsetMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages before scrolling past half", "commit": "60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b", "diff": "commit 60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:09:42 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3398957..83e50e7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,10 +81,12 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(isScrolledPastHalf()){\n+        if(!isScrolledPastHalf()){\n+            return\n+        }\n             isLoadingExtra = true\n         \n-        }\n+        \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n             messages.forEach((message) => {"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling based on scroll position", "commit": "2fb6be753efa7f2cc1e0183551a1ad655388b970", "diff": "commit 2fb6be753efa7f2cc1e0183551a1ad655388b970\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:11:14 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 83e50e7..9b0ed6c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,7 +81,7 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(!isScrolledPastHalf()){\n+        if(isScrolledPastHalf()){\n             return\n         }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "24cd378c9d8f857f4f28af1069a2f5523a6441d8", "diff": "commit 24cd378c9d8f857f4f28af1069a2f5523a6441d8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:21:03 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 83ae235..9e5a02b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -102,10 +102,15 @@ a {\n .chat-messages {\n   flex: 1;\n   overflow-y: auto;\n+  scrollbar-width: none;\n+  -ms-overflow-style: none;\n   padding: 10px;\n   height: 200px;\n }\n+.chat-messages::-webkit-scrollbar {\n+  display: none;\n+}\n \n .chat-messages .message {\n   display: flex;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use scroll instead of auto for chat messages", "commit": "8b98935d11496d51a0007db4b78391dde7a69163", "diff": "commit 8b98935d11496d51a0007db4b78391dde7a69163\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:26:16 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 9e5a02b..a928a72 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,7 +101,7 @@ a {\n \n .chat-messages {\n   flex: 1;\n-  overflow-y: auto;\n+  overflow-y: scroll;\n   scrollbar-width: none;\n   -ms-overflow-style: none;\n   padding: 10px;"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar and improve chat styling", "commit": "5b03ecda3f3f9ae515dd00a4e421255535a2f215", "diff": "commit 5b03ecda3f3f9ae515dd00a4e421255535a2f215\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:30:50 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex a928a72..7be47ca 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -79,17 +79,20 @@ a {\n   display: flex;\n   flex-direction: column;\n+  overflow: hidden;\n }\n \n .chat-header {\n   padding: 10px 20px;\n+  user-select: none;\n }\n \n .chat-header h2 {\n   font-size: 1.2em;\n+\n }\n \n .message-list {\n@@ -242,6 +245,7 @@ input[type=\"text\"], .chat-input textarea {\n   }\n   \n   .avatar {\n+    user-select: none;\n     opacity: 1;\n   }\n \n@@ -258,7 +262,7 @@ input[type=\"text\"], .chat-input textarea {\n \n ::-webkit-scrollbar {\n-  width: 6px;\n+    display:none;\n }\n \n ::-webkit-scrollbar-track {"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "3230c9f93bf9c3ae1b27474eac1cfc35f626d387", "diff": "commit 3230c9f93bf9c3ae1b27474eac1cfc35f626d387\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:32:28 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7be47ca..400c392 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -280,7 +280,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .chat-messages {\n-  scrollbar-width: thin;\n+  scrollbar-width: none;\n }"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improve infinite scroll trigger position", "commit": "6c58f4b26c628881aee5cbe0597ba489f705e42f", "diff": "commit 6c58f4b26c628881aee5cbe0597ba489f705e42f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:36:45 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 9b0ed6c..e236f5c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 2) {\n+        if (scrollTop < scrollableHeight / 4) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust scroll position threshold for infinite scrolling", "commit": "bc8a296223f3a2c6e07b126a78373aa5bb40399d", "diff": "commit bc8a296223f3a2c6e07b126a78373aa5bb40399d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:36:57 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e236f5c..ca03375 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 4) {\n+        if (scrollTop < scrollableHeight / 5) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll position threshold for infinite scrolling", "commit": "c77d2fb782258f787c5b52e3b27c5a3b0d468903", "diff": "commit c77d2fb782258f787c5b52e3b27c5a3b0d468903\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:42:45 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ca03375..b7ba523 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 5) {\n+        if (scrollTop > scrollableHeight / 1.2) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll threshold for infinite scrolling", "commit": "2e86ca2a3f1a4a8c746eecb46038a216b9706cdf", "diff": "commit 2e86ca2a3f1a4a8c746eecb46038a216b9706cdf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:44:31 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex b7ba523..f400996 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 1.2) {\n+        if (scrollTop > scrollableHeight / 5) {\n             return false;\n         }\n         return true;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Disable unnecessary scroll prevention", "commit": "1a608d8cfb1a3dcef9591b214fc54904615148bf", "diff": "commit 1a608d8cfb1a3dcef9591b214fc54904615148bf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:45:46 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex f400996..2c64c37 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,9 +62,9 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 5) {\n-            return false;\n-        }\n         return true;\n     }\n     let isLoadingExtra = false;"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Enable infinite scroll when near the bottom", "commit": "f0d76bd46af06637a21526c58d97f4b4d57f87dd", "diff": "commit f0d76bd46af06637a21526c58d97f4b4d57f87dd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:48:03 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2c64c37..3698913 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,10 +62,10 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        return true;\n+        if (scrollTop < scrollableHeight / 4) {\n+            return true;\n+        }\n+        return false;\n     }\n     let isLoadingExtra = false;\n     async function loadExtra() {\n@@ -81,7 +81,7 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(isScrolledPastHalf()){\n+        if(!isScrolledPastHalf()){\n             return\n         }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Disable scroll loading for now", "commit": "1b6ebf50080b0b86256e639031f36da92b8990b2", "diff": "commit 1b6ebf50080b0b86256e639031f36da92b8990b2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:49:29 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3698913..bf2d1dc 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -81,9 +81,9 @@\n         if(isLoadingExtra){\n             return \n         }\n-        if(!isScrolledPastHalf()){\n-            return\n-        }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correctly trigger infinite scroll", "commit": "6bdc6a7347a492f155629458c9b277cc16e04666", "diff": "commit 6bdc6a7347a492f155629458c9b277cc16e04666\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 19:57:48 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex bf2d1dc..09b3a14 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop < scrollableHeight / 4) {\n+        if (scrollTop > scrollableHeight / 2) {\n             return true;\n         }\n         return false;\n@@ -81,9 +81,9 @@\n         if(isLoadingExtra){\n             return \n         }\n+        if(!isScrolledPastHalf()){\n+           return\n+        }\n             isLoadingExtra = true"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correct infinite scroll trigger", "commit": "c042af8b800879ecfbb817089119aab75d839c32", "diff": "commit c042af8b800879ecfbb817089119aab75d839c32\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:00:35 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 09b3a14..65bc3af 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -62,7 +62,7 @@\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n \n-        if (scrollTop > scrollableHeight / 2) {\n+        if (scrollTop < scrollableHeight / 2) {\n             return true;\n         }\n         return false;\n@@ -89,10 +89,11 @@\n         \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n+            isLoadingExtra = false;\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n-            isLoadingExtra = false;\n+            updateLayout(false);\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3", "diff": "commit bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:17:17 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 65bc3af..adf0e55 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -89,11 +89,13 @@\n         \n             \n             const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n-            isLoadingExtra = false;\n+\n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n             })\n             updateLayout(false);\n+\n+            isLoadingExtra = false;\n     }\n     messagesContainer.addEventListener(\"scroll\",()=>{\n         loadExtra()"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correct initial message retrieval for infinite scroll", "commit": "2595594c3a99f6613e8e2194977fb1707c9f8b98", "diff": "commit 2595594c3a99f6613e8e2194977fb1707c9f8b98\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:20:32 2025 +0100\n\n    Scroll infinite.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex adf0e55..2e0ce52 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -88,7 +88,7 @@\n         \n         \n             \n-            const messages = await app.rpc.getMessages(channelUid, 1, firstMessage.dataset.created_at);\n+            const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n             messages.forEach((message) => {\n                 firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Added comments and improved message loading logic", "commit": "6555e4f8266b01963cfd660a4e175b01ab615c0c", "diff": "commit 6555e4f8266b01963cfd660a4e175b01ab615c0c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:31:18 2025 +0100\n\n    Refactor.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2e0ce52..83e2ff9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,4 +1,15 @@\n-{% extends \"app.html\" %} \n+{% comment \"Written by retoor@molodetz.nl\" %}\n+\n+{% comment \"This is a chat interface template using Jinja2 template syntax and JavaScript for handling user interactions, loading messages, and updating UI elements dynamically.\" %}\n+\n+{% comment \"There are no external imports that are not part of the templating language itself.\" %}\n+\n+{% comment \"MIT License\" %}\n+{% comment \"Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\" %}\n+{% comment \"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\" %}\n+{% comment \"THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\" %}\n+\n+{% extends \"app.html\" %}\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n@@ -46,6 +57,7 @@\n             time.innerText = app.timeDescription(time.dataset.created_at);\n         });\n     }\n+\n     function isElementVisible(element) {\n         const rect = element.getBoundingClientRect();\n         return (\n@@ -56,8 +68,8 @@\n         );\n     }\n     \n-\n     const messagesContainer = document.querySelector(\".chat-messages\");\n+\n     function isScrolledPastHalf() {\n         let scrollTop = messagesContainer.scrollTop;\n         let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n@@ -67,42 +79,36 @@\n         }\n         return false;\n     }\n+\n     let isLoadingExtra = false;\n+\n     async function loadExtra() {\n-    \n         const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if(isLoadingExtra){\n-            return \n+        if (isLoadingExtra) {\n+            return;\n         }\n-        if(!isScrolledPastHalf()){\n-           return\n+        if (!isScrolledPastHalf()) {\n+           return;\n         }\n-            isLoadingExtra = true\n         \n+        isLoadingExtra = true;\n         \n-            \n-            const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n+        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n-            messages.forEach((message) => {\n-                firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-            })\n-            updateLayout(false);\n+        messages.forEach((message) => {\n+            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+        })\n+        updateLayout(false);\n \n-            isLoadingExtra = false;\n+        isLoadingExtra = false;\n     }\n-    messagesContainer.addEventListener(\"scroll\",()=>{\n-        loadExtra()\n+\n+    messagesContainer.addEventListener(\"scroll\", () => {\n+        loadExtra();\n     });\n+\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n         let previousUser = null;\n         document.querySelectorAll(\".message\").forEach((message) => {\n@@ -114,10 +120,9 @@\n             }\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if(doScrollDown){ \n+        if (doScrollDown) { \n             lastMessage.scrollIntoView({ inline: \"nearest\" });\n         }\n-\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -133,7 +138,6 @@\n         const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n-\n         const message = document.createElement(\"div\");\n         message.dataset.color = data.color;\n         message.dataset.created_at = data.created_at;\n@@ -142,14 +146,12 @@\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);\n-        setTimeout(()=>{\n+        setTimeout(() => {\n             updateLayout(doScrollDownBecauseLastMessageIsVisible)\n-        },1000)\n+        }, 1000);\n     });\n \n     initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n-{% endblock %}\n-\n-\n+{% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Remove unnecessary comments from web.html", "commit": "2ab4341d0099799a84c0df6d91de33e1c5f69470", "diff": "commit 2ab4341d0099799a84c0df6d91de33e1c5f69470\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 20:34:24 2025 +0100\n\n    Refactor.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 83e2ff9..910f601 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,14 +1,3 @@\n-{% comment \"Written by retoor@molodetz.nl\" %}\n-\n-{% comment \"This is a chat interface template using Jinja2 template syntax and JavaScript for handling user interactions, loading messages, and updating UI elements dynamically.\" %}\n-\n-{% comment \"There are no external imports that are not part of the templating language itself.\" %}\n-\n-{% comment \"MIT License\" %}\n-{% comment \"Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\" %}\n-{% comment \"The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\" %}\n-{% comment \"THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\" %}\n-\n {% extends \"app.html\" %}\n \n {% block main %}\n@@ -154,4 +143,4 @@\n     initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n-{% endblock %}\n\\ No newline at end of file\n+{% endblock %}"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Update manifest and app.html for PWA support", "commit": "e21880b4f5fd15259e09c207ce54d8e86bd61ac7", "diff": "commit e21880b4f5fd15259e09c207ce54d8e86bd61ac7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:31:17 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 6d98b90..3a7200f 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -1,7 +1,20 @@\n {\n+    \"id\": \"snek\",\n     \"name\": \"Snek\",\n     \"description\": \"Danger noodle\",\n     \"display\": \"standalone\",\n+    \"orientation\": \"portrait\",\n+    \"scope\": \"/web.html\",\n+    \"related_applications\": [],\n+    \"prefer_related_applications\": false,\n+    \"screenshots\": [],\n+    \"dir\": \"ltr\",\n+    \"lang\": \"en-US\",\n+    \"launch_path\": \"/web.html\",\n+    \"display_override\": [\"browser\"],\n+    \"short_name\": \"Snek\",\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n@@ -10,4 +23,4 @@\n         \"sizes\": \"512x512\"\n       }\n     ]\n-  }\n\\ No newline at end of file\n+  }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 8caaef8..42ba78f 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -3,9 +3,12 @@\n <head>\n   <meta charset=\"UTF-8\">\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <link rel=\"manifest\" href=\"/manifest.json\" />\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <!-- \n   <script src=\"/push.js\"></script>\n+  -->\n   <script src=\"/fancy-button.js\"></script>\n   <script src=\"/upload-button.js\"></script>\n   <script src=\"/generic-form.js\"></script>\n@@ -13,7 +16,7 @@\n   <script src=\"/schedule.js\"></script>\n   <script src=\"/app.js\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n-  <link rel=\"manifest\" href=\"/manifest.json\" />\n+\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\n <body>\n@@ -45,20 +48,22 @@\n   </main>\n   <script>\n let installPrompt = null \n-        window.addEventListener(\"beforeinstallprompt\", async(event) => {\n-            event.preventDefault();\n-            installPrompt = event;\n-            document.addEventListener(\"DOMContentLoaded\", () => {\n-            alert(\"Jaaah\") \n+        window.addEventListener(\"beforeinstallprompt\", (e) => {\n+            installPrompt = e;\n             const button = document.getElementById(\"install-button\")\n+            \n+            button.style.display = 'inline-block'\n+            \n             button.addEventListener(\"click\", async ()=>{  \n-            const result = await installPrompt.prompt()\n-            console.info(result.outcome)\n+                const result = await installPrompt.prompt()\n+                console.info(result.outcome)\n             })\n-            button.style.display = 'inline-block'\n+\n             \n             })\n-        });\n+    \n         ;\n     </script>\n </body>"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Removed unnecessary display_override in manifest.json", "commit": "6c7266f20403f1f190c8b41f22d653c041dbbc77", "diff": "commit 6c7266f20403f1f190c8b41f22d653c041dbbc77\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:37:15 2025 +0100\n\n    Fixed.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 3a7200f..21acb7a 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -13,7 +13,6 @@\n     \"dir\": \"ltr\",\n     \"lang\": \"en-US\",\n     \"launch_path\": \"/web.html\",\n-    \"display_override\": [\"browser\"],\n     \"short_name\": \"Snek\",\n     \"start_url\": \"/web.html\",\n     \"icons\": ["}
{"repo": ".", "date": "2025-02-17", "line": "feat: Added 192x192 icon to manifest", "commit": "c745f609976397de0fb0cf7dec80205239b44b87", "diff": "commit c745f609976397de0fb0cf7dec80205239b44b87\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Feb 17 22:53:20 2025 +0100\n\n    Update manifest.\n\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 21acb7a..6af4db1 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -17,6 +17,11 @@\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n+        \"src\": \"/image/snek1.png\",\n+        \"type\": \"image/png\",\n+        \"sizes\": \"192x192\"\n+      },\n+        {\n         \"src\": \"/image/snek1.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"512x512\""}
{"repo": ".", "date": "2025-02-18", "line": "refactor: Switch to direct app execution and enable asyncio debugging", "commit": "3ccbe8be5c604d2683cf55553d1f11c674f6b930", "diff": "commit 3ccbe8be5c604d2683cf55553d1f11c674f6b930\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 12:28:49 2025 +0100\n\n    Updated asyncio debugging.\n\ndiff --git a/compose.yml b/compose.yml\nindex ced4111..a1079dd 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,8 +9,8 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=\"1\"\n      - PYTHONUNBUFFERED=\"1\"\n-    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n   snecssh:\n     build:\n       context: .\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex d77606f..4ce71ba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,5 +1,7 @@\n import pathlib\n \n+import asyncio\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,\n@@ -29,6 +31,7 @@ from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n \n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -121,8 +124,11 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n+async def main():\n+    loop = asyncio.get_event_loop()\n+    loop.set_debug(True)\n+    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":\n-\n-    web.run_app(app, port=8081, host=\"0.0.0.0\")\n+    asyncio.run(main())"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Standardize environment variables and add logging", "commit": "ebb520dd4a80b513d1eb6fc6ce90e6b46f905100", "diff": "commit ebb520dd4a80b513d1eb6fc6ce90e6b46f905100\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 12:38:44 2025 +0100\n\n    Updated asyncio debugging.\n\ndiff --git a/compose.yml b/compose.yml\nindex a1079dd..0090fff 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -7,8 +7,8 @@ services:\n     volumes:\n       - ./:/code\n     environment:\n-     - PYTHONDONTWRITEBYTECODE=\"1\"\n-     - PYTHONUNBUFFERED=\"1\"\n+     - PYTHONDONTWRITEBYTECODE=1\n+     - PYTHONUNBUFFERED=1\n     entrypoint: [\"python\",\"-m\",\"snek.app\"]\n   snecssh:\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 4ce71ba..ab337ba 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,7 +1,9 @@\n import pathlib\n-\n import asyncio\n \n+import logging \n+logging.basicConfig(level=logging.DEBUG)\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,"}
{"repo": ".", "date": "2025-02-18", "line": "feat: Integrated profiler for performance analysis", "commit": "c6620ad70afce9407c16793de8ab4fea35523d81", "diff": "commit c6620ad70afce9407c16793de8ab4fea35523d81\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:29:33 2025 +0100\n\n    Added profiler.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ab337ba..0fc1016 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,7 @@ import pathlib\n import asyncio\n \n import logging \n+\n logging.basicConfig(level=logging.DEBUG)\n \n from aiohttp import web\n@@ -32,7 +33,7 @@ from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n-\n+from snek.system.profiler import profiler_handler\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -43,7 +44,6 @@ async def session_middleware(request, handler):\n     response = await handler(request)\n     return response\n \n-\n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n@@ -77,6 +77,7 @@ class Application(BaseApplication):\n             name=\"static\",\n             show_index=True,\n         )\n+        self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n         self.router.add_view(\"/logout.json\", LogoutView)\n@@ -128,8 +129,6 @@ class Application(BaseApplication):\n \n async def main():\n-    loop = asyncio.get_event_loop()\n-    loop.set_debug(True)\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nnew file mode 100644\nindex 0000000..824208a\n--- /dev/null\n+++ b/src/snek/system/profiler.py\n@@ -0,0 +1,42 @@\n+import cProfile\n+import pstats\n+import sys \n+from aiohttp import web\n+profiler = None\n+import io \n+\n+\n+@web.middleware\n+async def profile_middleware(request, handler):\n+    global profiler \n+    if not profiler:\n+        profiler = cProfile.Profile()\n+    profiler.enable()\n+    response = await handler(request)\n+    profiler.disable()\n+    stats = pstats.Stats(profiler, stream=sys.stdout)\n+    stats.sort_stats('cumulative')\n+    stats.print_stats() \n+    return response\n+\n+async def profiler_handler(request):\n+    output = io.StringIO()\n+    stats = pstats.Stats(profiler, stream=output)\n+    stats.sort_stats('cumulative')\n+    stats.print_stats()\n+    return web.Response(text=output.getvalue())\n+\n+class Profiler:\n+\n+    def __init__(self):\n+        global profiler \n+        if profiler is None:\n+            profiler = cProfile.Profile()\n+        self.profiler = profiler\n+\n+    async def __aenter__(self):\n+        self.profiler.enable()    \n+    \n+    async def __aexit__(self, *args, **kwargs):\n+        self.profiler.disable()\n+\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 07893e1..05e447d 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -12,6 +12,7 @@ from snek.system.view import BaseView\n import traceback\n import json\n from snek.system.model import now \n+from snek.system.profiler import Profiler\n \n class RPCView(BaseView):\n \n@@ -169,8 +170,10 @@ class RPCView(BaseView):\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n-                    await rpc(msg.json())\n+                    async with Profiler():\n+                        await rpc(msg.json())\n                 except Exception as ex:\n+                    print(ex, flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Sort profiler stats by query parameter", "commit": "91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3", "diff": "commit 91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:47:34 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex 824208a..c5831e9 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -22,7 +22,8 @@ async def profile_middleware(request, handler):\n async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n-    stats.sort_stats('cumulative')\n+    sort_by = request.query.get(\"sort\", \"tot. percall\")\n+    stats.sort_stats(sory_by)\n     stats.print_stats()\n     return web.Response(text=output.getvalue())"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Corrected typo in profiler sorting", "commit": "60404c6fd31894f3fbb6ce31ba48f1750101748f", "diff": "commit 60404c6fd31894f3fbb6ce31ba48f1750101748f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Feb 18 13:48:48 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex c5831e9..193bbb7 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -23,7 +23,7 @@ async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n     sort_by = request.query.get(\"sort\", \"tot. percall\")\n-    stats.sort_stats(sory_by)\n+    stats.sort_stats(sort_by)\n     stats.print_stats()\n     return web.Response(text=output.getvalue())"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Added a1 emoji and long emoji", "commit": "736123c4aa313c51a7e0daee8cdd6dc7583547fd", "diff": "commit 736123c4aa313c51a7e0daee8cdd6dc7583547fd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 22:32:15 2025 +0100\n\n    Aadded a1\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 6410a9f..1b63aec 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -9,6 +9,63 @@ from jinja2.nodes import Const\n \n emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n \n+emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\"\"\"] = {\"en\": \":a1:\",\"status\":2,\"E\":0.6, \"alias\":[\":a1:\"]}\n+\n def set_link_target_blank(text):\n     soup = BeautifulSoup(text, 'html.parser')"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Increased gunicorn workers to 4", "commit": "2ad5a7b1f49704baf7b890fcfda7a87fddd456f7", "diff": "commit 2ad5a7b1f49704baf7b890fcfda7a87fddd456f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:20:10 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0090fff..0a42856 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,8 +9,8 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"python\",\"-m\",\"snek.app\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:\n       context: ."}
{"repo": ".", "date": "2025-02-19", "line": "fix: Reduced Gunicorn workers to 1", "commit": "e06824f4ec703388b7d55beeb5f1b3ef12452226", "diff": "commit e06824f4ec703388b7d55beeb5f1b3ef12452226\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:21:08 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0a42856..17bcf22 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:"}
{"repo": ".", "date": "2025-02-19", "line": "refactor: Increased Gunicorn workers and moved app instantiation to global scope", "commit": "821db3cb1a67c20a968ac1dd8ecc4263e511cf16", "diff": "commit 821db3cb1a67c20a968ac1dd8ecc4263e511cf16\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 19 23:23:24 2025 +0100\n\n    Changed server.\n\ndiff --git a/compose.yml b/compose.yml\nindex 17bcf22..0a42856 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0fc1016..8a3481e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -127,8 +127,10 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n+\n+\n async def main():\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n if __name__ == \"__main__\":"}
{"repo": ".", "date": "2025-02-20", "line": "feat: Improved database indexing and UI enhancements\n\nThis commit introduces database indexes for improved query performance and updates the UI with a container and styling for better organization and visual appeal. Additionally, it refines YouTube embedding and image handling, updates the manifest scope, and adjusts channel display labels. Finally, it removes sensitive data from RPC responses.", "commit": "3623286a9dfba330612c42e579abcca63ab186ed", "diff": "commit 3623286a9dfba330612c42e579abcca63ab186ed\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 20 05:56:31 2025 +0100\n\n    Changed server.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 8a3481e..3a61c2b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -65,6 +65,13 @@ class Application(BaseApplication):\n         self.setup_router()\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n+        if not self.db[\"user\"].has_index(\"username\"):\n+            self.db[\"user\"].create_index(\"username\", unique=True)\n+        if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n+            self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n+        if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n+            self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+    \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 400c392..e42784b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -111,6 +111,21 @@ a {\n   height: 200px;\n }\n+.container {\n+  flex: 1;\n+  padding: 10px;\n+  ul {\n+    list-style: none;\n+    margin: 0;\n+    padding: 0;\n+  }\n+  a {\n+        font-size: 20px;\n+      }\n+ \n+}\n+\n .chat-messages::-webkit-scrollbar {\n   display: none;\n }\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex 6af4db1..faa7381 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -4,7 +4,7 @@\n     \"description\": \"Danger noodle\",\n     \"display\": \"standalone\",\n     \"orientation\": \"portrait\",\n-    \"scope\": \"/web.html\",\n+    \"scope\": \"/\",\n     \"related_applications\": [],\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1b63aec..22d007d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -74,10 +74,6 @@ def set_link_target_blank(text):\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n         element.attrs['href'] = element.attrs['href'].strip(\".\")\n \n     return str(soup)\n \n@@ -93,7 +89,7 @@ def embed_youtube(text):\n def embed_image(text):\n     soup = BeautifulSoup(text, 'html.parser') \n     for element in soup.find_all(\"a\"):  \n-        for extension in [\".png\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n+        for extension in [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n             if extension in element.attrs['href'].lower():\n                 embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n                 element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 42ba78f..88a6335 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -34,7 +34,7 @@\n   <main>\n     {% block sidebar %}\n     <aside class=\"sidebar\">\n-      <h2 class=\"no-select\">Chat Rooms</h2>\n+      <h2 class=\"no-select\">Channels</h2>\n       <ul>\n         {% for channel in channels %}\n         <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 8bce656..b6a0439 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -2,25 +2,27 @@\n \n {% block title %}Search{% endblock %}\n \n-{% block main %}    \n+{% block main %}\n \n-    <section class=\"chat-area\">\n-        <div class=\"chat-header\"><h2>Search user</h2></div>\n-        <div class=\"chat-messages\">\n+<section class=\"chat-area\">\n+    <div class=\"chat-header\">\n+        <h2>Search user</h2>\n+    </div>\n+    <div class=\"container\">\n         <form method=\"get\" action=\"/search-user.html\">\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n-            <fancy-button size=\"auto\"  text=\"Search\" url=\"submit\"></fancy-button>\n+            <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>\n         </form>\n         <ul>\n-        {% for user in users %}\n-    <li>\n-        <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n-    </li>\n-    \n-    {% endfor %}\n-    </ul> \n+            {% for user in users %}\n+            <li>\n+                <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n+            </li>\n \n- \n-</div>\n-    </section>\n-{% endblock %}\n+            {% endfor %}\n+        </ul>\n+\n+\n+    </div>\n+</section>\n+{% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 05e447d..d98e53b 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -109,6 +109,24 @@ class RPCView(BaseView):\n             lowercase = query.lower()\n             if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n                 raise Exception(\"Not allowed\")\n+            records = [dict(record) async for record in self.services.channel.query(args[0])]\n+            for record in records:\n+                try:\n+                    del record['email']\n+                except KeyError:\n+                    pass \n+                try:\n+                    del record[\"password\"]\n+                except KeyError:\n+                    pass \n+                try:\n+                    del record['message']\n+                except:\n+                    pass\n+                try:\n+                    del record['html']\n+                except: \n+                    pass\n             return [dict(record) async for record in self.services.channel.query(args[0])]\n \n         async def __call__(self, data):"}
{"repo": ".", "date": "2025-02-20", "line": "refactor: Reduced gunicorn workers in compose file", "commit": "a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7", "diff": "commit a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Feb 20 23:24:44 2025 +0000\n\n    Chaned docker compose.\n\ndiff --git a/compose.yml b/compose.yml\nindex 0a42856..17bcf22 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -9,7 +9,7 @@ services:\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n-    entrypoint: [\"gunicorn\", \"-w\", \"4\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n+    entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n   snecssh:\n     build:"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Added sound effects for mentions", "commit": "54920e1545ffc68e2f928d3d042f5f11080f0d41", "diff": "commit 54920e1545ffc68e2f928d3d042f5f11080f0d41\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 21 00:24:14 2025 +0100\n\n    Added sound.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex b6b468b..7ddc9e8 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -263,7 +263,10 @@ class NotificationAudio {\n         this.schedule = new Schedule(timeout);\n     }\n \n-    sounds = [\"/audio/soundfx.d_beep3.mp3\"];\n+    sounds = [\n+        \"/audio/soundfx.d_beep3.mp3\",\n+        \"/audio/mention1.wav\"\n+    ];\n \n     play(soundIndex = 0) {\n         this.schedule.delay(() => {\ndiff --git a/src/snek/static/audio/mention1.wav b/src/snek/static/audio/mention1.wav\nnew file mode 100644\nindex 0000000..640937d\nBinary files /dev/null and b/src/snek/static/audio/mention1.wav differ\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 910f601..4d50b0f 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -116,11 +116,20 @@\n \n     setInterval(updateTimes, 1000);\n \n+    function isMention(message){\n+        const mentionText = '@{{ user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    \n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) return;\n \n         if (data.username !== \"{{ user.username.value }}\") {\n+            if(isMention(data.message)){\n+                app.playSound(1);\n+            }else{\n             app.playSound(0);\n+            }\n         }\n         \n         const messagesContainer = document.querySelector(\".chat-messages\");\n@@ -128,10 +137,6 @@\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n         const message = document.createElement(\"div\");\n-        message.dataset.color = data.color;\n-        message.dataset.created_at = data.created_at;\n-        message.dataset.user_nick = data.user_nick;\n-        message.dataset.uid = data.uid;\n         message.innerHTML = data.html;\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Implement distinct notification sounds for messages and mentions", "commit": "8ea41bb592b86e2f49b2f838e03006bc04472da5", "diff": "commit 8ea41bb592b86e2f49b2f838e03006bc04472da5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 21 00:41:22 2025 +0100\n\n    Addded notifs.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 7ddc9e8..b04e8fb 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -263,10 +263,12 @@ class NotificationAudio {\n         this.schedule = new Schedule(timeout);\n     }\n \n-    sounds = [\n-        \"/audio/soundfx.d_beep3.mp3\",\n-        \"/audio/mention1.wav\"\n-    ];\n+    sounds = {\n+        \"message\": \"/audio/soundfx.d_beep3.mp3\",\n+        \"mention\":  \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n+        \"messageOtherChannel\":  \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n+        \"ping\":  \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n+    }\n \n     play(soundIndex = 0) {\n         this.schedule.delay(() => {\ndiff --git a/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav b/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav\nnew file mode 100644\nindex 0000000..640937d\nBinary files /dev/null and b/src/snek/static/audio/750607__deadrobotmusic__notification-sound-1.wav differ\ndiff --git a/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav b/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav\nnew file mode 100644\nindex 0000000..ff5347c\nBinary files /dev/null and b/src/snek/static/audio/750608__deadrobotmusic__notification-sound-2.wav differ\ndiff --git a/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav b/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav\nnew file mode 100644\nindex 0000000..0334845\nBinary files /dev/null and b/src/snek/static/audio/750609__deadrobotmusic__notification-sound-3.wav differ\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 4d50b0f..fb935c8 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -116,19 +116,32 @@\n \n     setInterval(updateTimes, 1000);\n \n-    function isMention(message){\n+    function isMentionToMe(message){\n         const mentionText = '@{{ user.username.value }}';\n         return message.toLowerCase().includes(mentionText);\n     }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message){\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n     \n     app.addEventListener(\"channel-message\", (data) => {\n-        if (data.channel_uid !== channelUid) return;\n-\n+        if (data.channel_uid !== channelUid) {\n+            if(!isMentionForSomeoneElse(data.message)){\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+            \n+            return;\n+        }\n         if (data.username !== \"{{ user.username.value }}\") {\n-            if(isMention(data.message)){\n-                app.playSound(1);\n-            }else{\n-            app.playSound(0);\n+            if(isMentionToMe(data.message)){\n+                app.playSound(\"mention\");\n+            }else if (!isMentionForSomeoneElse(data.message)){\n+                app.playSound(\"message\");\n             }\n         }"}
{"repo": ".", "date": "2025-02-22", "line": "refactor: Moved sidebar channels to separate template and added channel notification", "commit": "fbe95d6631dfac2edc4c8600922020be4e15eccb", "diff": "commit fbe95d6631dfac2edc4c8600922020be4e15eccb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 22 01:21:44 2025 +0100\n\n    Side bar fix.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 88a6335..185d22e 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -33,14 +33,7 @@\n   </header>\n   <main>\n     {% block sidebar %}\n-    <aside class=\"sidebar\">\n-      <h2 class=\"no-select\">Channels</h2>\n-      <ul>\n-        {% for channel in channels %}\n-        <li><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}}</a></li>\n-        {% endfor %}\n-      </ul>\n-    </aside>\n+    {% include \"sidebar_channels.html\" %}\n     {% endblock %}\n     {% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex fb935c8..bdcddae 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -132,6 +132,7 @@\n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n+                channelSidebar.notify(data);\n                 app.playSound(\"messageOtherChannel\");\n             }\n             \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex dda589f..5a3b264 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -52,7 +52,7 @@ class WebView(BaseView):\n             other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n-                item[\"uid\"] = other_user[\"uid\"]\n+                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             else:\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]"}
{"repo": ".", "date": "2025-02-22", "line": "feat: Added channel sidebar with message counts and highlighting", "commit": "076fbb30fb51ecfb5b15d394c760f55dac26e1c1", "diff": "commit 076fbb30fb51ecfb5b15d394c760f55dac26e1c1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Feb 22 01:21:57 2025 +0100\n\n    Added sidebar stuff.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nnew file mode 100644\nindex 0000000..ed16ffd\n--- /dev/null\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -0,0 +1,64 @@\n+<style>\n+  .channel-list-item-highlight {\n+  }\n+</style>\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2 class=\"no-select\">Channels</h2>\n+    <ul>\n+      {% for channel in channels %}\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      {% endfor %}\n+    </ul>\n+  </aside>\n+  <script>\n+    class ChannelSidebar {\n+        constructor(el){\n+          this.el = el \n+        }\n+        get channelNodes() {\n+          return this.el.querySelectorAll(\"li\")\n+        }\n+        channelListItemByUid(channelUid){\n+          const id = \"channel-list-item-\" + channelUid;\n+          console.error(id)\n+          return document.getElementById(id)\n+        }\n+        incrementMessageCount(channelUid){\n+          let messageCount = this.getMessageCount(channelUid)\n+          messageCount++;\n+          this.setMessageCount(channelUid, messageCount)\n+        }\n+        getMessageCount(channelUid){\n+          const li = this.channelListItemByUid(channelUid);\n+          if(li){\n+            let messageCount = li.dataset['messageCount']\n+            if(!messageCount){\n+              return 0\n+            }\n+            return new Number(messageCount)\n+          }\n+        }\n+        setMessageCount(channelUid, count){\n+          const li = this.channelListItemByUid(channelUid);\n+          if(li){\n+            li.dataset.messageCount = new String(count)\n+            li.dataset['messageCount'] = count\n+            li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+          }\n+        }\n+        notify(message){\n+          const li = this.channelListItemByUid(message.channel_uid);\n+          if(li){\n+            this.incrementMessageCount(message.channel_uid)\n+            li.classList.add(\"channel-list-item-highlight\")\n+            li.querySelectorAll(\"a\").forEach(el=>{\n+              el.style.color = message.color\n+            })\n+          }\n+        }\n+\n+    }\n+    const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n+\n+  </script>\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Added avatar support and display in message view.", "commit": "5af4e5754b6902ae13c798d9793281d62b684590", "diff": "commit 5af4e5754b6902ae13c798d9793281d62b684590\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:22:36 2025 +0100\n\n    Update avatar.\n\ndiff --git a/Makefile b/Makefile\nindex f153fc1..e3febf7 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -5,13 +5,15 @@ GUNICORN=./.venv/bin/gunicorn\n GUNICORN_WORKERS = 1\n PORT = 8081\n \n+python:\n+\t$(PYTHON)\n \n+run:\n+\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n+\t\n install:\n \tpython3 -m venv .venv \n \t$(PIP) install -e .\n \n \n \n-run:\n-\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n-\t\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 2d5b6d9..e1075c9 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -28,6 +28,7 @@ dependencies = [\n     \"asyncssh\",\n     \"emoji\",\n     \"aiofiles\",\n-    \"PyJWT\"\n+    \"PyJWT\",\n+    \"multiavatar\"\n ]\n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3a61c2b..99f23e7 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -33,8 +33,10 @@ from snek.view.status import StatusView\n from snek.view.web import WebView\n from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n+from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n \n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -101,6 +103,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n         self.router.add_view(\"/search-user.json\", SearchUserView)\n+        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 2773f0a..e8afc31 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\">{{user_nick[0]}}</div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nnew file mode 100644\nindex 0000000..065ff6a\n--- /dev/null\n+++ b/src/snek/view/avatar.py\n@@ -0,0 +1,39 @@\n+\n+\n+\n+from multiavatar import multiavatar\n+\n+from aiohttp import web\n+from snek.system.view import BaseView\n+\n+class AvatarView(BaseView):\n+    login_required = True\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        avatar = multiavatar.multiavatar(uid,None, None)\n+        response = web.Response(text=avatar, content_type='image/svg+xml')\n+        response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n+        return response\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 05ffcaa..a80ec72 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -23,7 +23,9 @@ class UploadView(BaseView):\n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n-        return web.FileResponse(drive_item[\"path\"])\n+        response = web.FileResponse(drive_item[\"path\"])\n+        response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n+        return response\n \n     async def post(self):\n         reader = await self.request.multipart()"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatar IDs when requested", "commit": "e280e8776457605bbb5548fe9de5328b7b04bb8a", "diff": "commit e280e8776457605bbb5548fe9de5328b7b04bb8a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:40:18 2025 +0100\n\n    Update avatar.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 065ff6a..8281df1 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -24,7 +24,7 @@\n from multiavatar import multiavatar\n-\n+import uuid\n from aiohttp import web\n from snek.system.view import BaseView\n \n@@ -33,6 +33,8 @@ class AvatarView(BaseView):\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n+        if uid == \"unique\":\n+            uid = str(uuid.uuid4())\n         avatar = multiavatar.multiavatar(uid,None, None)\n         response = web.Response(text=avatar, content_type='image/svg+xml')\n         response.headers['Cache-Control'] = f'public, max-age={1337*42}'"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatars", "commit": "162cfe394558a085894a11cea57b193d6108b90e", "diff": "commit 162cfe394558a085894a11cea57b193d6108b90e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 00:46:48 2025 +0100\n\n    Update avatar.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 8281df1..54dbec0 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -35,7 +35,7 @@ class AvatarView(BaseView):\n         uid = self.request.match_info.get(\"uid\")\n         if uid == \"unique\":\n             uid = str(uuid.uuid4())\n-        avatar = multiavatar.multiavatar(uid,None, None)\n+        avatar = multiavatar.multiavatar(uid, True, None)\n         response = web.Response(text=avatar, content_type='image/svg+xml')\n         response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n         return response"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Updated welcome message and registration buttons", "commit": "da1be6301c79cf76383e6568f91ee23bdf5119f6", "diff": "commit da1be6301c79cf76383e6568f91ee23bdf5119f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Feb 26 06:21:48 2025 +0100\n\n    Text\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex c6d57df..1894bc4 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -11,12 +11,11 @@\n <body>\n   <div class=\"registration-container\">\n     <h1>Snek</h1>\n+    <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n+    So Snek came through, lean and optimized.</p>\n     <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n-    <span style=\"padding:10px;\">Or</span>\n+    <span style=\"padding:10px;\">OR</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n-    <a href=\"/about.html\">Design choices</a>\n-    <a href=\"/web.html\">App preview</a>\n-    <a href=\"/docs/docs/\">API docs</a>\n   </div>\n </body>\n </html>"}
{"repo": ".", "date": "2025-02-28", "line": "fix: Disable login requirement for avatar view", "commit": "66b85d146abac25df83edc1975db209b9d43fae7", "diff": "commit 66b85d146abac25df83edc1975db209b9d43fae7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Feb 28 15:42:52 2025 +0100\n\n    Non required avatar stuff.\n\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex 54dbec0..cbd973c 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -29,7 +29,7 @@ from aiohttp import web\n from snek.system.view import BaseView\n \n class AvatarView(BaseView):\n-    login_required = True\n+    login_required = False\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add last_message_on to ChannelModel and update on message send", "commit": "4620ebb800b5dd848ec28713f1afa20416698922", "diff": "commit 4620ebb800b5dd848ec28713f1afa20416698922\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 2 14:49:38 2025 +0100\n\n    Channel check.\n\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex d664087..8a40ced 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -9,3 +9,4 @@ class ChannelModel(BaseModel):\n     is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n     is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n+    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 02a4bd5..5eacbeb 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,12 +1,16 @@\n \n \n \n+from snek.system.model import now\n from snek.system.service import BaseService\n \n \n class ChatService(BaseService):\n \n     async def send(self,user_uid, channel_uid, message):\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        if not channel:\n+            raise Exception(\"Channel not found.\")\n         channel_message = await self.services.channel_message.create(\n             channel_uid, \n             user_uid, \n@@ -28,4 +32,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n+        channel['last_message_on'] = now()\n+        await self.services.channel.save(channel)\n         return sent_to_count\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add index creation with error handling", "commit": "e469e27abfedc0b08b483e0715b4dc9b16240c5e", "diff": "commit e469e27abfedc0b08b483e0715b4dc9b16240c5e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 2 14:59:25 2025 +0100\n\n    Optinal indexes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 99f23e7..6d9346d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -67,13 +67,17 @@ class Application(BaseApplication):\n         self.setup_router()\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n-        if not self.db[\"user\"].has_index(\"username\"):\n-            self.db[\"user\"].create_index(\"username\", unique=True)\n-        if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n-            self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n-        if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n-            self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n-    \n+\n+        try:\n+            if not self.db[\"user\"].has_index(\"username\"):\n+                self.db[\"user\"].create_index(\"username\", unique=True)\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+        except:\n+            pass \n+        \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)"}
{"repo": ".", "date": "2025-03-03", "line": "fix: Correctly handle trailing commas in link targets and improve upload view display", "commit": "45e3239cc06cdab0a8e5c1c1ef56593f65e750ea", "diff": "commit 45e3239cc06cdab0a8e5c1c1ef56593f65e750ea\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 3 00:35:08 2025 +0100\n\n    Upload changes.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 22d007d..0e71b80 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -73,7 +73,7 @@ def set_link_target_blank(text):\n         element.attrs['target'] = '_blank'\n         element.attrs['rel'] = 'noopener noreferrer'\n         element.attrs['referrerpolicy'] = 'no-referrer'\n-        element.attrs['href'] = element.attrs['href'].strip(\".\")\n+        element.attrs['href'] = element.attrs['href'].strip(\".\").strip(\",\")\n \n     return str(soup)\n \ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex a80ec72..f9bad33 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -77,7 +77,7 @@ class UploadView(BaseView):\n             await self.services.drive_item.save(drive_item)\n             response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            response = \"[url](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            response = \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement push notifications for service worker", "commit": "c3c94461c295ef4c6219051369472f983267437c", "diff": "commit c3c94461c295ef4c6219051369472f983267437c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:19:24 2025 +0100\n\n    Update service worker.\n\ndiff --git a/src/snek/static/service-worker.js b/src/snek/static/service-worker.js\nindex dfdc493..5881455 100644\n--- a/src/snek/static/service-worker.js\n+++ b/src/snek/static/service-worker.js\n@@ -1,3 +1,37 @@\n+async function requestNotificationPermission() {\n+  const permission = await Notification.requestPermission();\n+  return permission === 'granted';\n+}\n+\n+async function subscribeUser() {\n+  const registration = await navigator.serviceWorker.register('/service-worker.js');\n+  \n+  const subscription = await registration.pushManager.subscribe({\n+    userVisibleOnly: true,\n+    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)\n+  });\n+\n+  await fetch('/subscribe', {\n+    method: 'POST',\n+    body: JSON.stringify(subscription),\n+    headers: {\n+      'Content-Type': 'application/json'\n+    }\n+  });\n+}\n+\n+self.addEventListener('push', event => {\n+  const data = event.data.json();\n+  self.registration.showNotification(data.title, {\n+    body: data.message,\n+    icon: data.icon\n+  });\n+});\n+\n self.addEventListener(\"install\", (event) => {\n   console.log(\"Service worker installed\");\n });\n@@ -27,4 +61,4 @@ self.addEventListener(\"notificationclick\", (event) => {\n     event.notification.close();\n     event.waitUntil(clients.openWindow(\n-});"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement notification read functionality and unread stats", "commit": "578c182f2707a5f5b7c93f421e2035f7271aa60c", "diff": "commit 578c182f2707a5f5b7c93f421e2035f7271aa60c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:51:25 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 3815c60..27c65f4 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,9 +1,17 @@\n from snek.system.service import BaseService\n-\n+from snek.system.model import now\n \n class NotificationService(BaseService):\n     mapper_name = \"notification\"\n \n+    async def mark_as_read(self, user_uid, channel_message_uid):\n+        model = await self.get(user_uid, object_uid=channel_message_uid)\n+        model['read_at'] = now()\n+        await self.save(model)\n+\n+    async def get_unread_stats(self,user_uid):\n+        records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))\n+\n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n         model[\"object_uid\"] = object_uid\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex d98e53b..c197f24 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -39,6 +39,11 @@ class RPCView(BaseView):\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n+        async def mark_as_read(self, message_uid):\n+            self._require_login()\n+            await self.services.notification.mark_as_read(self.user_uid, message_uid)\n+            return True\n+\n         async def login(self, username, password):\n             success = await self.services.user.validate_login(username, password)\n             if not success:\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 5a3b264..8fd5ddc 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,6 +46,9 @@ class WebView(BaseView):\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n         )]\n+        for message in messages:\n+            await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n+\n         channels = []\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n             item = {}"}
{"repo": ".", "date": "2025-03-05", "line": "fix: Handle missing notification model in mark_as_read", "commit": "580ec5ab0d57a542ab38b25a6c508804d5bcfa21", "diff": "commit 580ec5ab0d57a542ab38b25a6c508804d5bcfa21\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:52:18 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 27c65f4..6db762c 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -6,9 +6,12 @@ class NotificationService(BaseService):\n \n     async def mark_as_read(self, user_uid, channel_message_uid):\n         model = await self.get(user_uid, object_uid=channel_message_uid)\n+        if not model:\n+            return False \n         model['read_at'] = now()\n         await self.save(model)\n-\n+        return True \n+    \n     async def get_unread_stats(self,user_uid):\n         records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add new_count to ChannelMember and increment on notification", "commit": "84d7b11f24b37cdc41ce9d5bb24be4080af14be9", "diff": "commit 84d7b11f24b37cdc41ce9d5bb24be4080af14be9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 17:59:59 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex d199498..65ba3e4 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -13,3 +13,4 @@ class ChannelMemberModel(BaseModel):\n     )\n     is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n     is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n+    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6db762c..ad0acbe 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,6 +36,8 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n+            channel_member['new_count'] += 1\n+            await self.services.channel_member.save(channel_member)\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display new notification count in RPC view", "commit": "d7851e645785fd707a7c7fdc5b6fff036e0c80f7", "diff": "commit d7851e645785fd707a7c7fdc5b6fff036e0c80f7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:02:02 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex c197f24..55accc7 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -94,6 +94,7 @@ class RPCView(BaseView):\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n                     \"tag\": channel[\"tag\"],\n+                    \"new_count\": subscription[\"new_count\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n                     \"is_read_only\": subscription[\"is_read_only\"]\n                 })"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "e1324e99bf06018a804a1a3dcc83f96cde04b1af", "diff": "commit e1324e99bf06018a804a1a3dcc83f96cde04b1af\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:05:34 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 5eacbeb..54e60dc 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,7 +17,10 @@ class ChatService(BaseService):\n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        \n+        if not channel_message['new_count']:\n+            channel_message['new_count'] = 0\n+        channel_message['new_count'] += 1\n+        await self.services.channel_message.save(channel_message)\n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex ad0acbe..6db762c 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,8 +36,6 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            channel_member['new_count'] += 1\n-            await self.services.channel_member.save(channel_member)\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count in chat and notification services", "commit": "afbf53938bd59e1e03dfc011063b27134dd0c054", "diff": "commit afbf53938bd59e1e03dfc011063b27134dd0c054\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:09:54 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 54e60dc..17c677b 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -17,10 +17,8 @@ class ChatService(BaseService):\n             message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        if not channel_message['new_count']:\n-            channel_message['new_count'] = 0\n-        channel_message['new_count'] += 1\n-        await self.services.channel_message.save(channel_message)\n+        \n+        \n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n         sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6db762c..a703f23 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -36,6 +36,11 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n+            if not channel_member['new_count']:\n+                channel_member['new_count'] = 0\n+            channel_member['new_count'] += 1\n+            await self.services.channel_member.save(channel_member)\n+            \n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added new_count to notifications", "commit": "8d3d7327d777ae3150ecaa237e137f8221310dfa", "diff": "commit 8d3d7327d777ae3150ecaa237e137f8221310dfa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:17:43 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a703f23..f5917e7 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -21,6 +21,7 @@ class NotificationService(BaseService):\n         model[\"object_type\"] = object_type\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n+        model['new_count'] = 1337\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")\n@@ -40,7 +41,7 @@ class NotificationService(BaseService):\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n             await self.services.channel_member.save(channel_member)\n-            \n+\n             model = await self.new()\n             model[\"object_uid\"] = channel_message_uid\n             model[\"object_type\"] = \"channel_message\""}
{"repo": ".", "date": "2025-03-05", "line": "feat: Remove unused new_count field from notification model", "commit": "4df6055566d61c769ae1759d81900d138093136e", "diff": "commit 4df6055566d61c769ae1759d81900d138093136e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:18:06 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f5917e7..399a0a1 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -21,7 +21,6 @@ class NotificationService(BaseService):\n         model[\"object_type\"] = object_type\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n-        model['new_count'] = 1337\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create notification: {model.errors}.\")"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment notification count and add debug print", "commit": "dd11c8da5acc5ed97a278a705e7282edc7d50bc5", "diff": "commit dd11c8da5acc5ed97a278a705e7282edc7d50bc5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:18:48 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 399a0a1..5a301a8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,6 +39,7 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n+            print(\"INSERTED!!\",flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Log model records during upsert", "commit": "8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d", "diff": "commit 8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:24:32 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 5a301a8..399a0a1 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,7 +39,6 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n-            print(\"INSERTED!!\",flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex d7e4163..e57b0fa 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -49,6 +49,7 @@ class BaseMapper:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n         model.updated_at.update()\n+        print(model.record,flush=True)\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Allow channel members to be created", "commit": "30fe0bfae7f2bc9badcb16f734655e661fe976ad", "diff": "commit 30fe0bfae7f2bc9badcb16f734655e661fe976ad\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:37:44 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex f34f08b..191a063 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -5,6 +5,7 @@ class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n+\n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d65f947..9feb759 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -61,7 +61,7 @@ class BaseService:\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n-            yield model\n+            yield await self.get(uid=model[\"uid\"])\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Return model directly from query", "commit": "edb35b57070a5659e8491a967880d816f2d07697", "diff": "commit edb35b57070a5659e8491a967880d816f2d07697\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:40:53 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex 9feb759..d65f947 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -61,7 +61,7 @@ class BaseService:\n         if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n-            yield await self.get(uid=model[\"uid\"])\n+            yield model\n \n     async def delete(self, **kwargs):\n         return await self.mapper.delete(**kwargs)"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display username when inserting notification", "commit": "0613f6f54de9247c17b4bae924197ffb4cbd2966", "diff": "commit 0613f6f54de9247c17b4bae924197ffb4cbd2966\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:48:27 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 399a0a1..f8a87c8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -39,6 +39,9 @@ class NotificationService(BaseService):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\n             channel_member['new_count'] += 1\n+            \n+            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Disable banned, muted, and deleted checks in channel member query", "commit": "1807cff67d3a4306f122d7df4436cc88137f299c", "diff": "commit 1807cff67d3a4306f122d7df4436cc88137f299c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Mar 5 18:51:50 2025 +0100\n\n    Notifications accept.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f8a87c8..6041901 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -32,9 +32,9 @@ class NotificationService(BaseService):\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n         ):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Implemented threads view with basic message display", "commit": "5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0", "diff": "commit 5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 7 20:58:53 2025 +0100\n\n    New stuff.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nnew file mode 100644\nindex 0000000..2d7d01b\n--- /dev/null\n+++ b/src/snek/templates/threads.html\n@@ -0,0 +1,153 @@\n+{% extends \"app.html\" %}\n+\n+{% block main %}\n+<section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">\n+        <h2>?</h2>\n+    </div>\n+    <div class=\"chat-messages\">\n+        {% for thread in threads %}\n+        {% autoescape false %}\n+        <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n+            data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n+            class=\"message\">\n+            <div class=\"avatar\" style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+                    src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n+            <div class=\"message-content\">\n+                <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n+                    endautoescape %}</div>\n+                <div class=\"time no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n+            </div>\n+        </div>\n+\n+        {% endautoescape %}\n+        {% endfor %}\n+    </div>\n+    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n+</section>\n+\n+<script>\n+\n+    function updateTimes() {\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+            time.innerText = app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n+\n+    function isElementVisible(element) {\n+        const rect = element.getBoundingClientRect();\n+        return (\n+            rect.top >= 0 &&\n+            rect.left >= 0 &&\n+            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+        );\n+    }\n+\n+    const messagesContainer = document.querySelector(\".chat-messages\");\n+\n+    function isScrolledPastHalf() {\n+        let scrollTop = messagesContainer.scrollTop;\n+        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n+\n+        if (scrollTop < scrollableHeight / 2) {\n+            return true;\n+        }\n+        return false;\n+    }\n+\n+    let isLoadingExtra = false;\n+\n+    async function loadExtra() {\n+        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n+        if (isLoadingExtra) {\n+            return;\n+        }\n+        if (!isScrolledPastHalf()) {\n+            return;\n+        }\n+\n+        isLoadingExtra = true;\n+\n+        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n+\n+        messages.forEach((message) => {\n+            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n+        })\n+        updateLayout(false);\n+\n+        isLoadingExtra = false;\n+    }\n+\n+    messagesContainer.addEventListener(\"scroll\", () => {\n+        loadExtra();\n+    });\n+\n+    function updateLayout(doScrollDown) {\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        updateTimes();\n+        let previousUser = null;\n+        document.querySelectorAll(\".message\").forEach((message) => {\n+            if (previousUser !== message.dataset.user_uid) {\n+                message.classList.add(\"switch-user\");\n+                previousUser = message.dataset.user_uid;\n+            } else {\n+                message.classList.remove(\"switch-user\");\n+            }\n+        });\n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        if (doScrollDown) {\n+            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+        }\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    function isMentionToMe(message) {\n+        const mentionText = '@{{ user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message) {\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) {\n+            if (!isMentionForSomeoneElse(data.message)) {\n+                channelSidebar.notify(data);\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+\n+            return;\n+        }\n+        if (data.username !== \"{{ user.username.value }}\") {\n+            if (isMentionToMe(data.message)) {\n+                app.playSound(\"mention\");\n+            } else if (!isMentionForSomeoneElse(data.message)) {\n+                app.playSound(\"message\");\n+            }\n+        }\n+\n+        const messagesContainer = document.querySelector(\".chat-messages\");\n+        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n+        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+\n+        const message = document.createElement(\"div\");\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n+        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n+        setTimeout(() => {\n+            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n+        }, 1000);\n+    });\n+\n+    initInputField(document.querySelector(\"textarea\"));\n+    updateLayout(true);\n+</script>\n+{% endblock %}\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nnew file mode 100644\nindex 0000000..d8f4359\n--- /dev/null\n+++ b/src/snek/view/threads.py\n@@ -0,0 +1,31 @@\n+from snek.system.view import BaseView\n+\n+class ThreadsView(BaseView):\n+\n+    async def get(self):\n+        threads = []\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        async for channel_member in user.get_channel_members():\n+            thread = {}\n+            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            thread[\"uid\"] = channel['uid']\n+            thread[\"name\"] = await channel_member.get_name()\n+            thread[\"new_count\"] = channel_member[\"new_count\"]\n+            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n+            thread['created_at'] = thread['last_message_on']\n+            last_message = await channel.get_last_message()\n+            if last_message:\n+                thread[\"last_message_text\"] = last_message[\"message\"]\n+                thread['last_message_user_uid'] = last_message[\"user_uid\"]\n+                user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n+                thread['last_message_user_nick'] = user_last_message[\"nick\"]\n+                thread['last_message_user_color'] = user_last_message['color']\n+            else:\n+                thread[\"last_message_text\"] = None \n+                thread['last_message_user_uid'] = None \n+                thread['last_message_user_nick'] = None     \n+                thread['last_message_user_color'] = None\n+            threads.append(thread)\n+\n+\n+        return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Added threads view and related model updates", "commit": "a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568", "diff": "commit a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 7 20:59:11 2025 +0100\n\n    New stuff.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6d9346d..e1f74e2 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,7 +1,9 @@\n import pathlib\n import asyncio\n \n-import logging \n+import logging\n+\n+from snek.view.threads import ThreadsView \n \n logging.basicConfig(level=logging.DEBUG)\n \n@@ -112,6 +114,7 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n+        self.router.add_view(\"/threads.html\", ThreadsView)\n \n         self.add_subapp(\n             \"/docs\",\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 8a40ced..0070948 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,3 +1,4 @@\n+from snek.model.channel_message import ChannelMessageModel\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -10,3 +11,11 @@ class ChannelModel(BaseModel):\n     is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n+\n+    async def get_last_message(self)->ChannelMessageModel:\n+        async for model in self.app.services.channel_message.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n+            return model \n+        return None\n+\n+    async def get_members(self):\n+        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 65ba3e4..5a8332e 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -14,3 +14,27 @@ class ChannelMemberModel(BaseModel):\n     is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n     is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n     new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n+\n+    async def get_user(self):\n+        return await self.app.services.user.get(uid=self['user_uid'])\n+    \n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self['channel_uid'])\n+\n+    async def get_name(self):\n+        if self[\"channel_uid\"] == \"dm\":\n+            user = await self.get_other_dm_user()\n+            return user['nick']\n+        channel = await self.get_channel()\n+        return channel['name']\n+\n+    async def get_other_dm_user(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+        \n+        async for model in self.app.services.channel_member.find(channel_uid=channel['uid']):\n+            if model[\"uid\"] != self['uid']:\n+                return await self.app.services.user.get(uid=model[\"user_uid\"])\n+        return await self.get_user()\n+        \n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0bda0bc..4b84fdc 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,3 +1,4 @@\n+from snek.model.user import UserModel\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -6,3 +7,9 @@ class ChannelMessageModel(BaseModel):\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n+\n+    async def get_user(self)->UserModel:\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+    \n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 54b6f30..0621ecf 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -31,3 +31,7 @@ class UserModel(BaseModel):\n     password = ModelField(name=\"password\", required=True, min_length=1)\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n+\n+    async def get_channel_members(self):\n+        async for channel_member in self.app.services.channel_member.find(user_uid=self['uid'],is_banned=False,deleted_at=None):\n+            yield channel_member\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 6041901..f8a87c8 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -32,9 +32,9 @@ class NotificationService(BaseService):\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n         ):\n             if not channel_member['new_count']:\n                 channel_member['new_count'] = 0\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex e57b0fa..e7415e1 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -20,7 +20,7 @@ class BaseMapper:\n         return self.app.db\n \n     async def new(self):\n-        return self.model_class(mapper=self)\n+        return self.model_class(mapper=self, app=self.app)\n \n     @property\n     def table(self):\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex ba3fc45..1aef4ae 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -270,7 +270,8 @@ class BaseModel:\n         return self\n \n     def __init__(self, *args, **kwargs):\n-        self._mapper = None\n+        self._mapper = kwargs.get(\"mapper\")\n+        self.app = kwargs.get(\"app\")\n         self.fields = {}\n         for key in dir(self.__class__):\n             obj = getattr(self.__class__, key)\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 185d22e..60109de 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -26,7 +26,7 @@\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n-      <a class=\"no-select\" href=\"/web.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Prevent race condition when reconnecting socket", "commit": "e3afc1ba6e97378688027a60d6d98cc19a519a8c", "diff": "commit e3afc1ba6e97378688027a60d6d98cc19a519a8c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 03:51:54 2025 +0100\n\n    Fix socket issue.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex b04e8fb..f26919e 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -183,8 +183,9 @@ class Socket extends EventHandler {\n         const me = this \n         if (this.isConnected || this.isConnecting) {\n             return new Promise((resolve) => {\n+                if(me.isConnected)resolve(me)\n+                else if(me.isConnecting)\n                 me.connectPromises.push(resolve);\n-                if (!me.isConnecting) resolve(me);\n             });\n         }\n         this.isConnecting = true;"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread display with consistent avatar styling", "commit": "37f6725f2f7e36ec03416f191c9d16cd864991ea", "diff": "commit 37f6725f2f7e36ec03416f191c9d16cd864991ea\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:07:21 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 2d7d01b..8131224 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div class=\"avatar\" style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Display user avatars in threads", "commit": "095be5892db198d0a6356c8700ed0c038e419a29", "diff": "commit 095be5892db198d0a6356c8700ed0c038e419a29\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:10:05 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 8131224..bb54f4d 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div style=\"background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div class=\"avatar\" style=\"display: block !important; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread avatar visibility", "commit": "9292e3b8f3b64084d6bcc0b13dd42d015f4799d9", "diff": "commit 9292e3b8f3b64084d6bcc0b13dd42d015f4799d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:11:34 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex bb54f4d..4141e23 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -11,7 +11,7 @@\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n             class=\"message\">\n-            <div class=\"avatar\" style=\"display: block !important; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n+            <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n                 <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve thread display and opacity", "commit": "24260f9c371ab2d989441e391f513f6460eaa1ec", "diff": "commit 24260f9c371ab2d989441e391f513f6460eaa1ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 04:24:39 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 4141e23..8ec781f 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -2,9 +2,6 @@\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\">\n-        <h2>?</h2>\n-    </div>\n     <div class=\"chat-messages\">\n         {% for thread in threads %}\n         {% autoescape false %}\n@@ -14,10 +11,10 @@\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n-                <div class=\"author\" style=\"color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"author\" style=\"opacity: 1; color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n                 <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n                     endautoescape %}</div>\n-                <div class=\"time no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n+                <div class=\"time opacity: 1; no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n             </div>\n         </div>"}
{"repo": ".", "date": "2025-03-08", "line": "refactor: Unified styling for chat messages and threads", "commit": "1b72063a5b972dd726c647b7397f0ced16bd66c2", "diff": "commit 1b72063a5b972dd726c647b7397f0ced16bd66c2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 07:22:14 2025 +0100\n\n    Channel list.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex e42784b..4405785 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -102,7 +102,7 @@ a {\n   overflow-y: auto;\n }\n \n-.chat-messages {\n+.chat-messages, .threads {\n   flex: 1;\n   overflow-y: scroll;\n   scrollbar-width: none;\n@@ -130,7 +130,7 @@ a {\n   display: none;\n }\n \n-.chat-messages .message {\n+.chat-messages .message, .threads .thread {\n   display: flex;\n   align-items: flex-start;\n   margin-bottom: 0;\n@@ -138,7 +138,7 @@ a {\n   border-radius: 8px;\n }\n \n-.chat-messages .message .avatar {\n+.chat-messages .message .avatar, .threads .thread .avatar {\n   width: 40px;\n   height: 40px;\n   border-radius: 50%;\n@@ -152,11 +152,11 @@ a {\n   margin-right: 15px;\n }\n \n-.chat-messages .message .message-content {\n+.chat-messages .message .message-content, .threads .thread .message-content {\n   flex: 1;\n }\n \n-.chat-messages .message .message-content .author {\n+.chat-messages .message .message-content .author, .threads .thread .message-content .author {\n   font-weight: bold;\n   margin-bottom: 3px;\n@@ -172,7 +172,7 @@ word-break: break-word;\n     overflow-wrap: break-word;\n     hyphens: auto;\n   }\n-.chat-messages .message .message-content .text {\n+.chat-messages .message .message-content .text, .threads .thread .message-content .text {\n   margin-bottom: 5px;\n   word-break: break-word;\n@@ -191,7 +191,7 @@ hyphens: auto;\n     }\n }\n \n-.chat-messages .message .message-content .time {\n+.chat-messages .message .message-content .time, .threads .thread .message-content .time {\n   font-size: 0.8em;\n }\n@@ -294,7 +294,7 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n-.chat-messages {\n+.chat-messages, .threads {\n   scrollbar-width: none;\n }\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 8ec781f..b982914 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -2,12 +2,12 @@\n \n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-messages\">\n+    <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}\n         <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n             data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n-            class=\"message\">\n+            class=\"thread\">\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n@@ -32,71 +32,11 @@\n         });\n     }\n \n-    function isElementVisible(element) {\n-        const rect = element.getBoundingClientRect();\n-        return (\n-            rect.top >= 0 &&\n-            rect.left >= 0 &&\n-            rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n-            rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n-        );\n-    }\n-\n-    const messagesContainer = document.querySelector(\".chat-messages\");\n-\n-    function isScrolledPastHalf() {\n-        let scrollTop = messagesContainer.scrollTop;\n-        let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;\n-\n-        if (scrollTop < scrollableHeight / 2) {\n-            return true;\n-        }\n-        return false;\n-    }\n \n-    let isLoadingExtra = false;\n-\n-    async function loadExtra() {\n-        const firstMessage = messagesContainer.querySelector(\".message:first-child\");\n-        if (isLoadingExtra) {\n-            return;\n-        }\n-        if (!isScrolledPastHalf()) {\n-            return;\n-        }\n-\n-        isLoadingExtra = true;\n-\n-        const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n-\n-        messages.forEach((message) => {\n-            firstMessage.insertAdjacentHTML(\"beforebegin\", message.html);\n-        })\n-        updateLayout(false);\n-\n-        isLoadingExtra = false;\n-    }\n-\n-    messagesContainer.addEventListener(\"scroll\", () => {\n-        loadExtra();\n-    });\n+    const threadsContainer = document.querySelector(\".chat-threads\");\n \n     function updateLayout(doScrollDown) {\n-        const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n-        let previousUser = null;\n-        document.querySelectorAll(\".message\").forEach((message) => {\n-            if (previousUser !== message.dataset.user_uid) {\n-                message.classList.add(\"switch-user\");\n-                previousUser = message.dataset.user_uid;\n-            } else {\n-                message.classList.remove(\"switch-user\");\n-            }\n-        });\n-        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if (doScrollDown) {\n-            lastMessage.scrollIntoView({ inline: \"nearest\" });\n-        }\n     }\n \n     setInterval(updateTimes, 1000);\n@@ -131,20 +71,17 @@\n             }\n         }\n \n-        const messagesContainer = document.querySelector(\".chat-messages\");\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n+        const threadsContainer = document.querySelector(\".chat-threads\");\n \n         const message = document.createElement(\"div\");\n         message.innerHTML = data.html;\n-        document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n-        updateLayout(doScrollDownBecauseLastMessageIsVisible);\n+        document.querySelector(\".chat-threads\").appendChild(message.firstChild);\n+        updateLayout();\n         setTimeout(() => {\n-            updateLayout(doScrollDownBecauseLastMessageIsVisible)\n+            updateLayout()\n         }, 1000);\n     });\n \n-    initInputField(document.querySelector(\"textarea\"));\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved thread display and DM channel naming\n\nfix: Corrected DM channel retrieval and added name color for DM threads", "commit": "5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a", "diff": "commit 5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 08:25:49 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 5a8332e..9689fa5 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -22,11 +22,11 @@ class ChannelMemberModel(BaseModel):\n         return await self.app.services.channel.get(uid=self['channel_uid'])\n \n     async def get_name(self):\n-        if self[\"channel_uid\"] == \"dm\":\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] == \"dm\":\n             user = await self.get_other_dm_user()\n             return user['nick']\n-        channel = await self.get_channel()\n-        return channel['name']\n+        return channel['name'] or self['label']\n \n     async def get_other_dm_user(self):\n         channel = await self.get_channel()\n@@ -37,4 +37,4 @@ class ChannelMemberModel(BaseModel):\n             if model[\"uid\"] != self['uid']:\n                 return await self.app.services.user.get(uid=model[\"user_uid\"])\n         return await self.get_user()\n-        \n\\ No newline at end of file\n+        \ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 191a063..42415d1 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -36,7 +36,11 @@ class ChannelMemberService(BaseService):\n     async def get_dm(self,from_user, to_user):\n         async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n             return model\n-        return None \n+        if not from_user == to_user:\n+            return None \n+        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+           \n+            return model \n     \n     async def get_other_dm_user(self, channel_uid, user_uid):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex b982914..73c256a 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -5,18 +5,18 @@\n     <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}\n-        <div style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n-            data-user_nick=\"{{last_message_user_nick}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{user_uid}}\"\n+        <a href=\"/channel/{{thread.uid}}.html\" style=\"max-width:100%;\" data-uid=\"{{thread.uid}}\" data-color=\"{{thread.last_message_user_color}}\" data-channel_uid=\"{{thread.uid}}\"\n+            data-name=\"{{thread.name}}\" data-created_at=\"{{thread.created_at}}\" data-user_uid=\"{{thread.user_uid}}\"\n             class=\"thread\">\n             <div class=\"avatar\" style=\"opacity: 1; background-color: {{thread.last_message_user_color}}; color: black;\"><img\n                     src=\"/avatar/{{thread.last_message_user_uid}}.svg\" /></div>\n             <div class=\"message-content\">\n-                <div class=\"author\" style=\"opacity: 1; color: {{thread.last_message_user_color}};\">{{thread.last_message_user_nick}}</div>\n+                <div class=\"author\" style=\"opacity: 1; color: {{thread.name_color}};\">{{thread.name}}</div>\n                 <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ thread.last_message_text }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n                     endautoescape %}</div>\n                 <div class=\"time opacity: 1; no-select\" data-created_at=\"{{thread.created_at}}\"></div>\n             </div>\n-        </div>\n+        </a>\n \n         {% endautoescape %}\n         {% endfor %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex bdcddae..2c48cb4 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -3,7 +3,7 @@\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n     <div class=\"chat-header\">\n-        <h2>{{ channel.label.value }}</h2>\n+        <h2>{{ name }}</h2>\n     </div>\n     <div class=\"chat-messages\">\n         {% for message in messages %}\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex d8f4359..772685f 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -8,23 +8,24 @@ class ThreadsView(BaseView):\n         async for channel_member in user.get_channel_members():\n             thread = {}\n             channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            last_message = await channel.get_last_message()\n+            if not last_message:\n+                continue\n+\n             thread[\"uid\"] = channel['uid']\n             thread[\"name\"] = await channel_member.get_name()\n             thread[\"new_count\"] = channel_member[\"new_count\"]\n             thread[\"last_message_on\"] = channel[\"last_message_on\"]\n             thread['created_at'] = thread['last_message_on']\n-            last_message = await channel.get_last_message()\n-            if last_message:\n-                thread[\"last_message_text\"] = last_message[\"message\"]\n-                thread['last_message_user_uid'] = last_message[\"user_uid\"]\n-                user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n-                thread['last_message_user_nick'] = user_last_message[\"nick\"]\n-                thread['last_message_user_color'] = user_last_message['color']\n-            else:\n-                thread[\"last_message_text\"] = None \n-                thread['last_message_user_uid'] = None \n-                thread['last_message_user_nick'] = None     \n-                thread['last_message_user_color'] = None\n+\n+            \n+            thread[\"last_message_text\"] = last_message[\"message\"]\n+            thread['last_message_user_uid'] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n+            if channel['tag'] == \"dm\":\n+                thread['name_color'] = user_last_message['color']\n+            thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n \n \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 8fd5ddc..3ae7902 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -42,6 +42,11 @@ class WebView(BaseView):\n                     return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n+        \n+        channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n             channel[\"uid\"]\n@@ -60,4 +65,6 @@ class WebView(BaseView):\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n-        return await self.render_template(\"web.html\", {\"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n\\ No newline at end of file\n+\n+        name = await channel_member.get_name()\n+        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Round follow-up messages corners", "commit": "98c2213a862b253f8f967f428b0b248bbe3a32f7", "diff": "commit 98c2213a862b253f8f967f428b0b248bbe3a32f7\nAuthor: BordedDev <>\nDate:   Sat Mar 8 17:25:56 2025 +0100\n\n    Fix rounded corners on follow-up messages\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 4405785..bcdc1d1 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,183 +1,189 @@\n * {\n-  margin: 0;\n-  box-sizing: border-box;\n+    margin: 0;\n+    box-sizing: border-box;\n }\n \n .gallery {\n-  padding: 50px;\n-  height: auto;\n-  overflow: auto;\n-  flex: 1;\n+    padding: 50px;\n+    height: auto;\n+    overflow: auto;\n+    flex: 1;\n }\n \n .gallery.tile, .tile {\n-  width: 100px;\n-  height: 100px;\n-  object-fit: cover;\n-  margin: 20px 10px 20px 0;\n-  border-radius: 5px;\n+    width: 100px;\n+    height: 100px;\n+    object-fit: cover;\n+    margin: 20px 10px 20px 0;\n+    border-radius: 5px;\n }\n \n body {\n-  font-family: Arial, sans-serif;\n-  line-height: 1.5;\n-  display: flex;\n-  flex-direction: column;\n-  height: 100vh;\n-  min-width: 100%;\n+    font-family: Arial, sans-serif;\n+    line-height: 1.5;\n+    display: flex;\n+    flex-direction: column;\n+    height: 100vh;\n+    min-width: 100%;\n }\n \n main {\n-  display: flex;\n-  flex: 1;\n-  overflow: hidden;\n+    display: flex;\n+    flex: 1;\n+    overflow: hidden;\n }\n \n header {\n-  padding: 10px 20px;\n-  display: flex;\n-  justify-content: space-between;\n-  align-items: center;\n+    padding: 10px 20px;\n+    display: flex;\n+    justify-content: space-between;\n+    align-items: center;\n }\n \n header .logo {\n-  font-size: 1.5em;\n-  font-weight: bold;\n+    font-size: 1.5em;\n+    font-weight: bold;\n }\n \n header nav a {\n-  text-decoration: none;\n-  margin-left: 15px;\n-  font-size: 1em;\n-  transition: color 0.3s;\n+    text-decoration: none;\n+    margin-left: 15px;\n+    font-size: 1em;\n+    transition: color 0.3s;\n }\n \n .no-select {\n-  -webkit-user-select: none;  \n-  -moz-user-select: none;     \n-  -ms-user-select: none;      \n-  user-select: none;          \n+    -webkit-user-select: none;\n+    -moz-user-select: none;\n+    -ms-user-select: none;\n+    user-select: none;\n }\n \n header nav a:hover {\n }\n \n a {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n+    font-weight: bold;\n+    margin-bottom: 3px;\n }\n \n .chat-area {\n-  flex: 1;\n-  display: flex;\n-  flex-direction: column;\n-  overflow: hidden;\n+    flex: 1;\n+    display: flex;\n+    flex-direction: column;\n+    overflow: hidden;\n }\n \n .chat-header {\n-  padding: 10px 20px;\n-  user-select: none;\n+    padding: 10px 20px;\n+    user-select: none;\n }\n \n .chat-header h2 {\n-  font-size: 1.2em;\n+    font-size: 1.2em;\n \n }\n \n .message-list {\n-  flex: 1;\n-  height: 200px;\n-  padding-bottom: 40px;\n-  overflow-y: auto;\n+    flex: 1;\n+    height: 200px;\n+    padding-bottom: 40px;\n+    overflow-y: auto;\n }\n \n .chat-messages, .threads {\n-  flex: 1;\n-  overflow-y: scroll;\n-  scrollbar-width: none;\n-  -ms-overflow-style: none;\n-  padding: 10px;\n-  height: 200px;\n+    flex: 1;\n+    overflow-y: scroll;\n+    scrollbar-width: none;\n+    -ms-overflow-style: none;\n+    padding: 10px;\n+    height: 200px;\n }\n+\n .container {\n-  flex: 1;\n-  padding: 10px;\n-  ul {\n-    list-style: none;\n-    margin: 0;\n-    padding: 0;\n-  }\n-  a {\n+    flex: 1;\n+    padding: 10px;\n+\n+    ul {\n+        list-style: none;\n+        margin: 0;\n+        padding: 0;\n+    }\n+\n+    a {\n         font-size: 20px;\n-      }\n- \n+    }\n+\n }\n \n .chat-messages::-webkit-scrollbar {\n-  display: none;\n+    display: none;\n }\n \n .chat-messages .message, .threads .thread {\n-  display: flex;\n-  align-items: flex-start;\n-  margin-bottom: 0;\n-  padding: 5px;\n-  border-radius: 8px;\n+    display: flex;\n+    align-items: flex-start;\n+    margin-bottom: 0;\n+    padding: 5px;\n+    border-radius: 8px;\n }\n \n .chat-messages .message .avatar, .threads .thread .avatar {\n-  width: 40px;\n-  height: 40px;\n-  border-radius: 50%;\n-  font-size: 1em;\n-  font-weight: bold;\n-  display: flex;\n-  justify-content: center;\n-  align-items: center;\n-  margin-right: 15px;\n+    width: 40px;\n+    height: 40px;\n+    border-radius: 50%;\n+    font-size: 1em;\n+    font-weight: bold;\n+    display: flex;\n+    justify-content: center;\n+    align-items: center;\n+    margin-right: 15px;\n }\n \n .chat-messages .message .message-content, .threads .thread .message-content {\n-  flex: 1;\n+    flex: 1;\n }\n \n .chat-messages .message .message-content .author, .threads .thread .message-content .author {\n-  font-weight: bold;\n-  margin-bottom: 3px;\n+    font-weight: bold;\n+    margin-bottom: 3px;\n }\n+\n * {\n-word-break: break-word;\n-  overflow-wrap: break-word;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n     hyphens: auto;\n }\n+\n .highlight pre {\n     white-space: pre-wrap;\n     word-break: break-word;\n     overflow-wrap: break-word;\n     hyphens: auto;\n-  }\n+}\n+\n .chat-messages .message .message-content .text, .threads .thread .message-content .text {\n-  margin-bottom: 5px;\n-  word-break: break-word;\n-  overflow-wrap: break-word;\n-hyphens: auto; \n+    margin-bottom: 5px;\n+    word-break: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n }\n \n .message-content {\n@@ -187,151 +193,153 @@ hyphens: auto;\n .message-content {\n \n     img, video, iframe, div {\n-        max-width: 100%; \n+        max-width: 90%;\n+        border-radius: 20px;\n     }\n }\n \n .chat-messages .message .message-content .time, .threads .thread .message-content .time {\n-  font-size: 0.8em;\n+    font-size: 0.8em;\n }\n \n .chat-input {\n-  padding: 15px;\n-  display: flex;\n-  align-items: center;\n+    padding: 15px;\n+    display: flex;\n+    align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n-  flex: 1;\n-  color: white;\n-  border: none;\n-  padding: 10px;\n-  border-radius: 5px;\n-  resize: none;\n+    flex: 1;\n+    color: white;\n+    border: none;\n+    padding: 10px;\n+    border-radius: 5px;\n+    resize: none;\n }\n \n .chat-input upload-button {\n-  color: white;\n-  border: none;\n-  padding: 10px 15px;\n-  margin-left: 10px;\n-  border-radius: 5px;\n-  cursor: pointer;\n-  font-size: 1em;\n-  transition: background-color 0.3s;\n+    color: white;\n+    border: none;\n+    padding: 10px 15px;\n+    margin-left: 10px;\n+    border-radius: 5px;\n+    cursor: pointer;\n+    font-size: 1em;\n+    transition: background-color 0.3s;\n }\n \n .chat-input button:hover {\n }\n \n @media (max-width: 768px) {\n-  .sidebar {\n-    display: none;\n-  }\n+    .sidebar {\n+        display: none;\n+    }\n }\n \n .message {\n-  .text {\n-    max-width: 100%;\n-    word-wrap: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n-  }\n+    .text {\n+        max-width: 100%;\n+        word-wrap: break-word;\n+        overflow-wrap: break-word;\n+        hyphens: auto;\n+    }\n \n-  .avatar {\n-    opacity: 0;\n-  }\n+    .avatar {\n+        opacity: 0;\n+    }\n \n-  .author, .time {\n-    display: none;\n-  }\n+    .author, .time {\n+        display: none;\n+    }\n }\n \n .message.switch-user {\n-  .text img,iframe, video {\n-    max-width: 90%;\n-    border-radius: 20px;\n-  }\n-  \n-  .avatar {\n-    user-select: none;\n-    opacity: 1;\n-  }\n+    .text img, iframe, video {\n+        max-width: 90%;\n+        border-radius: 20px;\n+    }\n \n-  .author {\n-    display: block;\n-  }\n+    .avatar {\n+        user-select: none;\n+        opacity: 1;\n+    }\n+\n+    .author {\n+        display: block;\n+    }\n }\n \n-.message:has(+ .message.switch-user), .message:last-child{ \n+.message:has(+ .message.switch-user), .message:last-child {\n     .time {\n-  display: block;\n-}\n+        display: block;\n+    }\n }\n \n ::-webkit-scrollbar {\n-    display:none;\n+    display: none;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n-  border-radius: 3px;\n+    border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n .chat-messages, .threads {\n-  scrollbar-width: none;\n+    scrollbar-width: none;\n }\n \n a {\n-    text-decoration:none\n+    text-decoration: none\n }\n+\n .sidebar {\n-  width: 250px;\n-  padding: 20px;\n-  overflow-y: auto;\n+    width: 250px;\n+    padding: 20px;\n+    overflow-y: auto;\n }\n \n .sidebar h2 {\n-  font-size: 1.2em;\n-  margin-bottom: 20px;\n+    font-size: 1.2em;\n+    margin-bottom: 20px;\n }\n \n .sidebar ul {\n-  list-style: none;\n+    list-style: none;\n }\n \n .sidebar ul li {\n-  margin-bottom: 15px;\n+    margin-bottom: 15px;\n }\n \n .sidebar ul li a {\n-  text-decoration: none;\n-  font-size: 1em;\n-  transition: color 0.3s;\n+    text-decoration: none;\n+    font-size: 1em;\n+    transition: color 0.3s;\n }\n \n .sidebar ul li a:hover {\n }"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved user search display with thread-like layout and real-time updates", "commit": "0a9b66d2f76a2c4418db7149b17729bf8a2dc811", "diff": "commit 0a9b66d2f76a2c4418db7149b17729bf8a2dc811\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 17:33:17 2025 +0100\n\n    Updated search.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex b6a0439..842b982 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -13,16 +13,90 @@\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n             <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>\n         </form>\n-        <ul>\n+        <div class=\"threads\">\n             {% for user in users %}\n-            <li>\n-                <a href=\"/channel/{{user.uid.value}}.html\">{{user.username.value}}</a>\n-            </li>\n+        <a href=\"/channel/{{user.uid}}.html\" style=\"max-width:100%;\" data-uid=\"{{user.uid}}\" data-color=\"{{user.color}}\" data-channel_uid=\"{{user.uid}}\"\n+                                                                                                                         data-username=\"{{user.username}}\" data-nick=\"{{user.nick}}\" data-last_ping=\"{{user.last_ping}}\" \n+            class=\"thread\">\n+            <div class=\"avatar\" style=\"opacity: 1; background-color: {{user.color}}; color: black;\"><img\n+                    src=\"/avatar/{{user.uid}}.svg\" /></div>\n+            <div class=\"message-content\">\n+                <div class=\"author\" style=\"opacity: 1; color: {{user.color}};\">{{user.nick}}</div>\n+                <div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{%raw %}{% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{%\n+                    endautoescape %}</div>\n+                <div class=\"time opacity: 1; no-select\" data-created_at=\"{{user.last_ping}}\">{{user.last_ping}}</div>\n+            </div>\n+        </a>\n \n-            {% endfor %}\n-        </ul>\n \n \n+            {% endfor %}\n+    </div>\n+\n     </div>\n </section>\n-{% endblock %}\n\\ No newline at end of file\n+<script>\n+\n+    document.querySelector(\"[name=query]\").focus();\n+\n+    function updateTimes() {\n+        document.querySelectorAll(\".time\").forEach((time) => {\n+            time.innerText = \"Last seen: \" + app.timeDescription(time.dataset.created_at);\n+        });\n+    }\n+\n+\n+    const threadsContainer = document.querySelector(\".chat-threads\");\n+\n+    function updateLayout(doScrollDown) {\n+        updateTimes();\n+    }\n+\n+    setInterval(updateTimes, 1000);\n+\n+    function isMentionToMe(message) {\n+        const mentionText = '@{{ current_user.username.value }}';\n+        return message.toLowerCase().includes(mentionText);\n+    }\n+    function extractMentions(message) {\n+        return [...new Set(message.match(/@\\w+/g) || [])];\n+    }\n+    function isMentionForSomeoneElse(message) {\n+        const mentions = extractMentions(message);\n+        const mentionText = '@{{ current_user.username.value }}';\n+        return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n+    }\n+\n+    app.addEventListener(\"channel-message\", (data) => {\n+        if (data.channel_uid !== channelUid) {\n+            if (!isMentionForSomeoneElse(data.message)) {\n+                channelSidebar.notify(data);\n+                app.playSound(\"messageOtherChannel\");\n+            }\n+\n+            return;\n+        }\n+        if (data.username !== \"{{ current_user.username.value }}\") {\n+            if (isMentionToMe(data.message)) {\n+                app.playSound(\"mention\");\n+            } else if (!isMentionForSomeoneElse(data.message)) {\n+                app.playSound(\"message\");\n+            }\n+        }\n+\n+        const threadsContainer = document.querySelector(\".chat-threads\");\n+\n+        const message = document.createElement(\"div\");\n+        message.innerHTML = data.html;\n+        document.querySelector(\".chat-threads\").appendChild(message.firstChild);\n+        updateLayout();\n+        setTimeout(() => {\n+            updateLayout()\n+        }, 1000);\n+    });\n+\n+    updateLayout(true);\n+</script>\n+\n+\n+{% endblock %}\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex c28b883..347d3b2 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -40,14 +40,14 @@ class SearchUserView(BaseFormView):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n-            users = await self.app.services.user.search(query)\n+            users = [user.record for user in  await self.app.services.user.search(query)]\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-\n-        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or ''})\n+        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or '','current_user': current_user})\n \n     async def submit(self, form):\n         if await form.is_valid:\n             return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Set default page titles and update login/register titles", "commit": "6ecd356cc08e17596ff6b5007c46def2bc17c851", "diff": "commit 6ecd356cc08e17596ff6b5007c46def2bc17c851\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:04:53 2025 +0100\n\n    Made improvement to page titles (mainly setting a default, login and regsiter)\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 36a012c..0785366 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -1,26 +1,25 @@\n <!DOCTYPE html>\n <html lang=\"en\">\n <head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>{% block title %}{% endblock %}</title>\n-  <script src=\"/app.js\"></script>\n-  <script src=\"/message-list.js\"></script>\n-  <style>{{ highlight_styles }}</style>\n-  <link rel=\"stylesheet\" href=\"/style.css\">\n-   <script src=\"/fancy-button.js\"></script>\n+    <meta charset=\"UTF-8\">\n+    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n+    <script src=\"/app.js\"></script>\n+    <script src=\"/message-list.js\"></script>\n+    <style>{{ highlight_styles }}</style>\n+    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <script src=\"/fancy-button.js\"></script>\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n-    <link rel=\"stylesheet\" href=\"/html-frame.css\"></script>\n-  \n+    <link rel=\"stylesheet\" href=\"/html-frame.css\">\n </head>\n <body>\n-    <header>\n-        {% block header %}\n-        {% endblock %}\n+<header>\n+    {% block header %}\n+    {% endblock %}\n \n-    </header>\n-    <main>\n+</header>\n+<main>\n     {% block main %}\n     {% endblock %}\n </main>\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 0a6bcc1..0ffc379 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -1,7 +1,11 @@\n {% extends \"base.html\" %}\n \n+{% block title %}\n+    Login - Snek chat by Molodetz\n+{% endblock %}\n+\n {% block main %}\n-  <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-   <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+    <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n \n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex f8d1067..2b783a7 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -1,7 +1,11 @@\n {% extends \"base.html\" %}\n \n+{% block title %}\n+    Register - Snek chat by Molodetz\n+{% endblock %}\n+\n {% block main %}\n-<fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-  \n-  <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+\n+    <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improved form submission and change event handling", "commit": "804556b74d8caa5e3a79a03cd1a8d7870843b898", "diff": "commit 804556b74d8caa5e3a79a03cd1a8d7870843b898\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:09:14 2025 +0100\n\n    Fixed issues with auto complete not working correctly with form sync\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 730d70a..e71d817 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -62,7 +62,7 @@ class GenericField extends HTMLElement {\n \n   constructor() {\n     super();\n-    this.attachShadow({ mode: 'open' });\n+    this.attachShadow({mode: 'open'});\n     this.container = document.createElement('div');\n     this.styleElement = document.createElement('style');\n     this.styleElement.innerHTML = `\n@@ -165,18 +165,26 @@ class GenericField extends HTMLElement {\n       const me = this;\n       this.inputElement.addEventListener(\"keyup\", (e) => {\n         if (e.key === 'Enter') {\n+          const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n+          me.dispatchEvent(event);\n+\n           me.dispatchEvent(new Event(\"submit\"));\n         } else if (me.field.value !== e.target.value) {\n-          const event = new CustomEvent(\"change\", { detail: me, bubbles: true });\n+          const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n           me.dispatchEvent(event);\n         }\n       });\n \n       this.inputElement.addEventListener(\"click\", (e) => {\n-        const event = new CustomEvent(\"click\", { detail: me, bubbles: true });\n+        const event = new CustomEvent(\"click\", {detail: me, bubbles: true});\n         me.dispatchEvent(event);\n       });\n \n+      this.inputElement.addEventListener(\"blur\", (e) => {\n+        const event = new CustomEvent(\"change\", {detail: me, bubbles: true});\n+        me.dispatchEvent(event);\n+      }, true);\n+\n       this.container.appendChild(this.inputElement);\n     }\n \n@@ -226,7 +234,7 @@ class GenericForm extends HTMLElement {\n \n   constructor() {\n     super();\n-    this.attachShadow({ mode: 'open' });\n+    this.attachShadow({mode: 'open'});\n     this.styleElement = document.createElement(\"style\");\n     this.styleElement.innerHTML = `\n \n@@ -307,6 +315,16 @@ class GenericForm extends HTMLElement {\n             }\n           }\n         });\n+\n+        fieldElement.addEventListener(\"submit\", async (e) => {\n+          const isValid = await this.validate();\n+          if (isValid) {\n+            const saveResult = await this.submit();\n+            if (saveResult.redirect_url) {\n+              window.location.pathname = saveResult.redirect_url;\n+            }\n+          }\n+        })\n       });\n \n     } catch (error) {\n@@ -322,7 +340,7 @@ class GenericForm extends HTMLElement {\n       headers: {\n         'Content-Type': 'application/json'\n       },\n-      body: JSON.stringify({ \"action\": \"validate\", \"form\": this.form })\n+      body: JSON.stringify({\"action\": \"validate\", \"form\": this.form})\n     });\n \n     const form = await response.json();\n@@ -353,7 +371,7 @@ class GenericForm extends HTMLElement {\n       headers: {\n         'Content-Type': 'application/json'\n       },\n-      body: JSON.stringify({ \"action\": \"submit\", \"form\": this.form })\n+      body: JSON.stringify({\"action\": \"submit\", \"form\": this.form})\n     });\n     return await response.json();\n   }"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added meta information to base.html", "commit": "7c22a70722db3fa97a813c30c01c4cd5462138eb", "diff": "commit 7c22a70722db3fa97a813c30c01c4cd5462138eb\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:25:15 2025 +0100\n\n    Added additional meta info to base.html\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 0785366..1e89c50 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -3,7 +3,15 @@\n <head>\n     <meta charset=\"UTF-8\">\n     <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+    <meta name=\"application-name\" content=\"Snek chat by Molodetz\">\n+    <meta name=\"description\" content=\"Snek chat by Molodetz\">\n+    <meta name=\"author\" content=\"Molodetz\">\n+    <meta name=\"keywords\" content=\"snek, chat, molodetz\">\n+    <meta name=\"color-scheme\" content=\"dark\">\n+\n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n+\n     <script src=\"/app.js\"></script>\n     <script src=\"/message-list.js\"></script>\n     <style>{{ highlight_styles }}</style>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort threads by last message timestamp", "commit": "8e195a49e3e914a4b241e95378bd9a07611715a8", "diff": "commit 8e195a49e3e914a4b241e95378bd9a07611715a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 18:41:37 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 772685f..14431f1 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -27,6 +27,7 @@ class ThreadsView(BaseView):\n                 thread['name_color'] = user_last_message['color']\n             thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n-\n+            \n+        threads.sort(key=lambda x: x['last_message_on'], reverse=True)\n \n         return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-08", "line": "style: Improved form input focus styling", "commit": "c9113ca09500c5b3cc277fb09b9607a505d39f30", "diff": "commit c9113ca09500c5b3cc277fb09b9607a505d39f30\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:56:55 2025 +0100\n\n    Tweaked form input styling\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex e71d817..11647dc 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -75,16 +75,28 @@ class GenericField extends HTMLElement {\n       }\n \n       input {\n-          width: 90%;\n-          padding: 10px;\n-          margin: 10px 0;\n-          border-radius: 5px;\n-          font-size: 1em;\n+        width: 90%;\n+        padding: 10px;\n+        margin: 10px 0;\n+        border-radius: 5px;\n+        font-size: 1em;\n+        \n+        &:focus {\n+        }\n+        \n+        &::placeholder {\n+          transition: opacity 0.3s;\n+        }\n+        \n+        &:focus::placeholder {\n+          opacity: 0.4;\n+        }\n       }\n-\n+      \n       button {\n           width: 50%;\n           padding: 10px;"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added head block for custom HTML head content", "commit": "62aa15a4b4d6514824378cca73084c9ce2df903b", "diff": "commit 62aa15a4b4d6514824378cca73084c9ce2df903b\nAuthor: BordedDev <>\nDate:   Sat Mar 8 18:59:00 2025 +0100\n\n    Added head block\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex 1e89c50..d93b568 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -20,6 +20,9 @@\n     <script src=\"/html-frame.js\"></script>\n     <script src=\"/generic-form.js\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n+\n+    {% block head %}\n+    {% endblock %}\n </head>\n <body>\n <header>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented back button styling and layout", "commit": "ad7eab9717848584369ded6e19babb6a7b9f5b98", "diff": "commit ad7eab9717848584369ded6e19babb6a7b9f5b98\nAuthor: BordedDev <>\nDate:   Sat Mar 8 19:07:09 2025 +0100\n\n    Tweaked the back button position\n\ndiff --git a/src/snek/static/back-form.css b/src/snek/static/back-form.css\nnew file mode 100644\nindex 0000000..4824d6c\n--- /dev/null\n+++ b/src/snek/static/back-form.css\n@@ -0,0 +1,18 @@\n+.back-form {\n+  display: grid;\n+  grid-template-columns: auto auto;\n+  grid-template-rows: auto auto;\n+\n+  fancy-button {\n+    grid-column: 1 / 1;\n+    grid-row: 1 / 1;\n+    z-index: 1;\n+    margin-left: 30px;\n+    margin-top: 30px;\n+  }\n+\n+  generic-form {\n+    grid-column: 1 / 3;\n+    grid-row: 1 / 3;\n+  }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex 0ffc379..ed81224 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -4,8 +4,13 @@\n     Login - Snek chat by Molodetz\n {% endblock %}\n \n-{% block main %}\n-    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-    <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+{% block head %}\n+    <link rel=\"stylesheet\" href=\"/back-form.css\">\n+{% endblock %}\n \n+{% block main %}\n+    <div class=\"back-form\">\n+        <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+        <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+    </div>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 2b783a7..2fa89d3 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -4,8 +4,14 @@\n     Register - Snek chat by Molodetz\n {% endblock %}\n \n+{% block head %}\n+    <link rel=\"stylesheet\" href=\"/back-form.css\">\n+{% endblock %}\n+\n {% block main %}\n-    <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n+    <div class=\"back-form\">\n+        <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n \n-    <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+    </div>\n {% endblock %}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Revert auto formatting changes", "commit": "e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0", "diff": "commit e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0\nAuthor: BordedDev <>\nDate:   Sat Mar 8 19:35:22 2025 +0100\n\n    Undo auto formatting\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex bcdc1d1..3d154a9 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,345 +1,345 @@\n * {\n-    margin: 0;\n-    box-sizing: border-box;\n+  margin: 0;\n+  box-sizing: border-box;\n }\n \n .gallery {\n-    padding: 50px;\n-    height: auto;\n-    overflow: auto;\n-    flex: 1;\n+  padding: 50px;\n+  height: auto;\n+  overflow: auto;\n+  flex: 1;\n }\n \n .gallery.tile, .tile {\n-    width: 100px;\n-    height: 100px;\n-    object-fit: cover;\n-    margin: 20px 10px 20px 0;\n-    border-radius: 5px;\n+  width: 100px;\n+  height: 100px;\n+  object-fit: cover;\n+  margin: 20px 10px 20px 0;\n+  border-radius: 5px;\n }\n \n body {\n-    font-family: Arial, sans-serif;\n-    line-height: 1.5;\n-    display: flex;\n-    flex-direction: column;\n-    height: 100vh;\n-    min-width: 100%;\n+  font-family: Arial, sans-serif;\n+  line-height: 1.5;\n+  display: flex;\n+  flex-direction: column;\n+  height: 100vh;\n+  min-width: 100%;\n }\n \n main {\n-    display: flex;\n-    flex: 1;\n-    overflow: hidden;\n+  display: flex;\n+  flex: 1;\n+  overflow: hidden;\n }\n \n header {\n-    padding: 10px 20px;\n-    display: flex;\n-    justify-content: space-between;\n-    align-items: center;\n+  padding: 10px 20px;\n+  display: flex;\n+  justify-content: space-between;\n+  align-items: center;\n }\n \n header .logo {\n-    font-size: 1.5em;\n-    font-weight: bold;\n+  font-size: 1.5em;\n+  font-weight: bold;\n }\n \n header nav a {\n-    text-decoration: none;\n-    margin-left: 15px;\n-    font-size: 1em;\n-    transition: color 0.3s;\n+  text-decoration: none;\n+  margin-left: 15px;\n+  font-size: 1em;\n+  transition: color 0.3s;\n }\n \n .no-select {\n-    -webkit-user-select: none;\n-    -moz-user-select: none;\n-    -ms-user-select: none;\n-    user-select: none;\n+  -webkit-user-select: none;\n+  -moz-user-select: none;\n+  -ms-user-select: none;\n+  user-select: none;\n }\n \n header nav a:hover {\n }\n \n a {\n-    font-weight: bold;\n-    margin-bottom: 3px;\n+  font-weight: bold;\n+  margin-bottom: 3px;\n }\n \n .chat-area {\n-    flex: 1;\n-    display: flex;\n-    flex-direction: column;\n-    overflow: hidden;\n+  flex: 1;\n+  display: flex;\n+  flex-direction: column;\n+  overflow: hidden;\n }\n \n .chat-header {\n-    padding: 10px 20px;\n-    user-select: none;\n+  padding: 10px 20px;\n+  user-select: none;\n }\n \n .chat-header h2 {\n-    font-size: 1.2em;\n+  font-size: 1.2em;\n \n }\n \n .message-list {\n-    flex: 1;\n-    height: 200px;\n-    padding-bottom: 40px;\n-    overflow-y: auto;\n+  flex: 1;\n+  height: 200px;\n+  padding-bottom: 40px;\n+  overflow-y: auto;\n }\n \n .chat-messages, .threads {\n-    flex: 1;\n-    overflow-y: scroll;\n-    scrollbar-width: none;\n-    -ms-overflow-style: none;\n-    padding: 10px;\n-    height: 200px;\n+  flex: 1;\n+  overflow-y: scroll;\n+  scrollbar-width: none;\n+  -ms-overflow-style: none;\n+  padding: 10px;\n+  height: 200px;\n }\n \n .container {\n-    flex: 1;\n-    padding: 10px;\n+  flex: 1;\n+  padding: 10px;\n \n-    ul {\n-        list-style: none;\n-        margin: 0;\n-        padding: 0;\n-    }\n+  ul {\n+    list-style: none;\n+    margin: 0;\n+    padding: 0;\n+  }\n \n-    a {\n-        font-size: 20px;\n-    }\n+  a {\n+    font-size: 20px;\n+  }\n \n }\n \n .chat-messages::-webkit-scrollbar {\n-    display: none;\n+  display: none;\n }\n \n .chat-messages .message, .threads .thread {\n-    display: flex;\n-    align-items: flex-start;\n-    margin-bottom: 0;\n-    padding: 5px;\n-    border-radius: 8px;\n+  display: flex;\n+  align-items: flex-start;\n+  margin-bottom: 0;\n+  padding: 5px;\n+  border-radius: 8px;\n }\n \n .chat-messages .message .avatar, .threads .thread .avatar {\n-    width: 40px;\n-    height: 40px;\n-    border-radius: 50%;\n-    font-size: 1em;\n-    font-weight: bold;\n-    display: flex;\n-    justify-content: center;\n-    align-items: center;\n-    margin-right: 15px;\n+  width: 40px;\n+  height: 40px;\n+  border-radius: 50%;\n+  font-size: 1em;\n+  font-weight: bold;\n+  display: flex;\n+  justify-content: center;\n+  align-items: center;\n+  margin-right: 15px;\n }\n \n .chat-messages .message .message-content, .threads .thread .message-content {\n-    flex: 1;\n+  flex: 1;\n }\n \n .chat-messages .message .message-content .author, .threads .thread .message-content .author {\n-    font-weight: bold;\n-    margin-bottom: 3px;\n+  font-weight: bold;\n+  margin-bottom: 3px;\n }\n \n * {\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .highlight pre {\n-    white-space: pre-wrap;\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  white-space: pre-wrap;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .chat-messages .message .message-content .text, .threads .thread .message-content .text {\n-    margin-bottom: 5px;\n-    word-break: break-word;\n-    overflow-wrap: break-word;\n-    hyphens: auto;\n+  margin-bottom: 5px;\n+  word-break: break-word;\n+  overflow-wrap: break-word;\n+  hyphens: auto;\n }\n \n .message-content {\n-    max-width: 100%;\n+  max-width: 100%;\n }\n \n .message-content {\n \n-    img, video, iframe, div {\n-        max-width: 90%;\n-        border-radius: 20px;\n-    }\n+  img, video, iframe, div {\n+    max-width: 90%;\n+    border-radius: 20px;\n+  }\n }\n \n .chat-messages .message .message-content .time, .threads .thread .message-content .time {\n-    font-size: 0.8em;\n+  font-size: 0.8em;\n }\n \n .chat-input {\n-    padding: 15px;\n-    display: flex;\n-    align-items: center;\n+  padding: 15px;\n+  display: flex;\n+  align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n-    flex: 1;\n-    color: white;\n-    border: none;\n-    padding: 10px;\n-    border-radius: 5px;\n-    resize: none;\n+  flex: 1;\n+  color: white;\n+  border: none;\n+  padding: 10px;\n+  border-radius: 5px;\n+  resize: none;\n }\n \n .chat-input upload-button {\n-    color: white;\n-    border: none;\n-    padding: 10px 15px;\n-    margin-left: 10px;\n-    border-radius: 5px;\n-    cursor: pointer;\n-    font-size: 1em;\n-    transition: background-color 0.3s;\n+  color: white;\n+  border: none;\n+  padding: 10px 15px;\n+  margin-left: 10px;\n+  border-radius: 5px;\n+  cursor: pointer;\n+  font-size: 1em;\n+  transition: background-color 0.3s;\n }\n \n .chat-input button:hover {\n }\n \n @media (max-width: 768px) {\n-    .sidebar {\n-        display: none;\n-    }\n+  .sidebar {\n+    display: none;\n+  }\n }\n \n .message {\n-    .text {\n-        max-width: 100%;\n-        word-wrap: break-word;\n-        overflow-wrap: break-word;\n-        hyphens: auto;\n-    }\n+  .text {\n+    max-width: 100%;\n+    word-wrap: break-word;\n+    overflow-wrap: break-word;\n+    hyphens: auto;\n+  }\n \n-    .avatar {\n-        opacity: 0;\n-    }\n+  .avatar {\n+    opacity: 0;\n+  }\n \n-    .author, .time {\n-        display: none;\n-    }\n+  .author, .time {\n+    display: none;\n+  }\n }\n \n .message.switch-user {\n-    .text img, iframe, video {\n-        max-width: 90%;\n-        border-radius: 20px;\n-    }\n+  .text img, iframe, video {\n+    max-width: 90%;\n+    border-radius: 20px;\n+  }\n \n-    .avatar {\n-        user-select: none;\n-        opacity: 1;\n-    }\n+  .avatar {\n+    user-select: none;\n+    opacity: 1;\n+  }\n \n-    .author {\n-        display: block;\n-    }\n+  .author {\n+    display: block;\n+  }\n }\n \n .message:has(+ .message.switch-user), .message:last-child {\n-    .time {\n-        display: block;\n-    }\n+  .time {\n+    display: block;\n+  }\n }\n \n ::-webkit-scrollbar {\n-    display: none;\n+  display: none;\n }\n \n ::-webkit-scrollbar-track {\n }\n \n ::-webkit-scrollbar-thumb {\n-    border-radius: 3px;\n+  border-radius: 3px;\n }\n \n ::-webkit-scrollbar-thumb:hover {\n }\n \n .chat-messages, .threads {\n-    scrollbar-width: none;\n+  scrollbar-width: none;\n }\n \n a {\n-    text-decoration: none\n+  text-decoration: none\n }\n \n .sidebar {\n-    width: 250px;\n-    padding: 20px;\n-    overflow-y: auto;\n+  width: 250px;\n+  padding: 20px;\n+  overflow-y: auto;\n }\n \n .sidebar h2 {\n-    font-size: 1.2em;\n-    margin-bottom: 20px;\n+  font-size: 1.2em;\n+  margin-bottom: 20px;\n }\n \n .sidebar ul {\n-    list-style: none;\n+  list-style: none;\n }\n \n .sidebar ul li {\n-    margin-bottom: 15px;\n+  margin-bottom: 15px;\n }\n \n .sidebar ul li a {\n-    text-decoration: none;\n-    font-size: 1em;\n-    transition: color 0.3s;\n+  text-decoration: none;\n+  font-size: 1em;\n+  transition: color 0.3s;\n }\n \n .sidebar ul li a:hover {\n }"}
{"repo": ".", "date": "2025-03-08", "line": "Merge main", "commit": "dd5a9a23e8e452a03f7080656608617309bf73a5", "diff": "commit dd5a9a23e8e452a03f7080656608617309bf73a5\nMerge: e91ae45 8e195a4\nAuthor: BordedDev <bordeddev@noreply@molodetz.nl>\nDate:   Sat Mar 8 18:40:43 2025 +0000\n\n    Merge branch 'main' into main"}
{"repo": ".", "date": "2025-03-08", "line": "Merge: Tweaks for login/registration and base + image roundness", "commit": "aedfe9aa947dcd2262c825af5a4d977eb298ccb5", "diff": "commit aedfe9aa947dcd2262c825af5a4d977eb298ccb5\nMerge: 8e195a4 dd5a9a2\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sat Mar 8 18:53:02 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort channels by last message time", "commit": "a219ce4d79a15ef900583ab025fb0da1df79ace3", "diff": "commit a219ce4d79a15ef900583ab025fb0da1df79ace3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:21:02 2025 +0100\n\n    Update sorting.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 3ae7902..be44df9 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -58,6 +58,9 @@ class WebView(BaseView):\n         async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n             item = {}\n             other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n+            parent_object = await subscribed_channel.get_channel()\n+            last_message =await parent_object.get_last_message()\n+            item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n@@ -65,6 +68,8 @@ class WebView(BaseView):\n                 item[\"name\"] = subscribed_channel[\"label\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n+        \n+        channels.sort(key=lambda x: x['last_message_on'], reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Separate public and private channels in sidebar", "commit": "d6061cb45b68a7b393b0e35a560d5d7bea4b9478", "diff": "commit d6061cb45b68a7b393b0e35a560d5d7bea4b9478\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:27:05 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex ed16ffd..9dd20b5 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -6,7 +6,13 @@\n <aside class=\"sidebar\" id=\"channelSidebar\">\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n-      {% for channel in channels %}\n+      {% for channel in channels if not channel['is_private'] %}\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      {% endfor %}\n+    </ul>\n+    <h2 class=\"no-select\">Private</h2>\n+    <ul>\n+      {% for channel in channels if channel['is_private'] %}\n       <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n       {% endfor %}\n     </ul>\n@@ -61,4 +67,4 @@\n     }\n     const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n \n-  </script>\n\\ No newline at end of file\n+  </script>\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex be44df9..6444ce3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -61,6 +61,7 @@ class WebView(BaseView):\n             parent_object = await subscribed_channel.get_channel()\n             last_message =await parent_object.get_last_message()\n             item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+            item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n             if other_user:\n                 item[\"name\"] = other_user[\"nick\"]\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message, handling null values", "commit": "24a504e3a7383c7a338fbe3ee09411547eed58eb", "diff": "commit 24a504e3a7383c7a338fbe3ee09411547eed58eb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:29:33 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 6444ce3..77fb4ff 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,7 +70,7 @@ class WebView(BaseView):\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n         \n-        channels.sort(key=lambda x: x['last_message_on'], reverse=True)\n+        channels.sort(key=lambda x: x['last_message_on'] or 'zzzzz', reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message time, handling null values", "commit": "11b8f0e744fb9d6b05ce11b7475bb3f51edee96b", "diff": "commit 11b8f0e744fb9d6b05ce11b7475bb3f51edee96b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 8 20:29:48 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 77fb4ff..cdde6e3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -70,7 +70,7 @@ class WebView(BaseView):\n                 item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n             channels.append(item)\n         \n-        channels.sort(key=lambda x: x['last_message_on'] or 'zzzzz', reverse=True)\n+        channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n         name = await channel_member.get_name()\n         return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented form preloading and autofocus on the first input element for login and register pages", "commit": "0266b2a559952d0ff767b251c2921704a6aa1abe", "diff": "commit 0266b2a559952d0ff767b251c2921704a6aa1abe\nAuthor: BordedDev <>\nDate:   Sat Mar 8 21:04:32 2025 +0100\n\n    Added form preloading, and autofocus on the first input element\n    Also adds the preloading functionality to login & register pages\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 11647dc..73e55cb 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -98,58 +98,58 @@ class GenericField extends HTMLElement {\n       }\n       \n       button {\n-          width: 50%;\n-          padding: 10px;\n-          border: none;\n-          float: right;\n-          margin-top: 10px;\n-          margin-left: 10px;\n-          margin-right: 10px;\n-          border-radius: 5px;\n-          color: white;\n-          font-size: 1em;\n-          font-weight: bold;\n-          cursor: pointer;\n-          transition: background-color 0.3s;\n-          clear: both;\n+        width: 50%;\n+        padding: 10px;\n+        border: none;\n+        float: right;\n+        margin-top: 10px;\n+        margin-left: 10px;\n+        margin-right: 10px;\n+        border-radius: 5px;\n+        color: white;\n+        font-size: 1em;\n+        font-weight: bold;\n+        cursor: pointer;\n+        transition: background-color 0.3s;\n+        clear: both;\n       }\n \n       button:hover {\n       }\n \n       a {\n-          text-decoration: none;\n-          display: block;\n-          margin-top: 15px;\n-          font-size: 0.9em;\n-          transition: color 0.3s;\n+        text-decoration: none;\n+        display: block;\n+        margin-top: 15px;\n+        font-size: 0.9em;\n+        transition: color 0.3s;\n       }\n \n       a:hover {\n       }\n \n       .valid {\n-          border: 1px solid green;\n-          color: green;\n-          font-size: 0.9em;\n-          margin-top: 5px;\n+        border: 1px solid green;\n+        color: green;\n+        font-size: 0.9em;\n+        margin-top: 5px;\n       }\n \n       .error {\n         border: 3px solid red;\n-          font-size: 0.9em;\n-          margin-top: 5px;\n+        font-size: 0.9em;\n+        margin-top: 5px;\n       }\n \n       @media (max-width: 500px) {\n-          input {\n-              width: 90%;\n-          }\n+        input {\n+          width: 90%;\n+        }\n       }\n     `;\n     this.container.appendChild(this.styleElement);\n@@ -165,7 +165,13 @@ class GenericField extends HTMLElement {\n     this[name] = value;\n   }\n \n+  focus(options) {\n+    this.inputElement?.focus(options);\n+  }\n+\n   updateAttributes() {\n+    const inputUpdate = this.inputElement != null;\n+\n     if (this.inputElement == null && this.field) {\n       this.inputElement = document.createElement(this.field.tag);\n       if (this.field.tag === 'button' && this.field.value === \"submit\") {\n@@ -218,7 +224,9 @@ class GenericField extends HTMLElement {\n     }\n     this.inputElement.setAttribute(\"tabindex\", this.field.index);\n     this.inputElement.classList.add(this.field.name);\n-    this.value = this.field.value;\n+    if (this.field.value != null || !inputUpdate) {\n+      this.value = this.field.value;\n+    }\n \n     let place_holder = this.field.place_holder ?? null;\n     if (this.field.required && place_holder) {\n@@ -281,43 +289,73 @@ class GenericForm extends HTMLElement {\n   }\n \n   connectedCallback() {\n+    const preloadedForm = this.getAttribute('preloaded-structure');\n+    if (preloadedForm) {\n+      try {\n+        const form = JSON.parse(preloadedForm);\n+        this.constructForm(form)\n+      } catch (error) {\n+        console.error(error, preloadedForm);\n+      }\n+    }\n     const url = this.getAttribute('url');\n     if (url) {\n       const fullUrl = url.startsWith(\"/\") ? window.location.origin + url : new URL(window.location.origin + \"/http-get\");\n       if (!url.startsWith(\"/\")) {\n         fullUrl.searchParams.set('url', url);\n       }\n-      this.loadForm(fullUrl.toString());\n+      this.loadForm(fullUrl.toString())\n     } else {\n       this.container.textContent = \"No URL provided!\";\n     }\n   }\n \n-  async loadForm(url) {\n+  async constructForm(formPayload) {\n     try {\n-      const response = await fetch(url);\n-      if (!response.ok) {\n-        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n-      }\n-      this.form = await response.json();\n+      this.form = formPayload;\n \n       let fields = Object.values(this.form.fields);\n \n+      let hasAutoFocus = Object.keys(this.fields).length !== 0;\n+\n       fields.sort((a, b) => a.index - b.index);\n       fields.forEach(field => {\n-        const fieldElement = document.createElement('generic-field');\n-        this.fields[field.name] = fieldElement;\n+        const updatingField = field.name in this.fields\n+\n+        this.fields[field.name] ??= document.createElement('generic-field');\n+\n+        const fieldElement = this.fields[field.name];\n+\n         fieldElement.setAttribute(\"form\", this);\n         fieldElement.setAttribute(\"field\", field);\n-        this.container.appendChild(fieldElement);\n+\n         fieldElement.updateAttributes();\n \n-        fieldElement.addEventListener(\"change\", (e) => {\n-          this.form.fields[e.detail.name].value = e.detail.value;\n-        });\n+        if (!updatingField) {\n+          this.container.appendChild(fieldElement);\n+\n+          if (!hasAutoFocus && field.tag === \"input\") {\n+            fieldElement.focus();\n+            hasAutoFocus = true;\n+          }\n+\n+          fieldElement.addEventListener(\"change\", (e) => {\n+            this.form.fields[e.detail.name].value = e.detail.value;\n+          });\n+\n+          fieldElement.addEventListener(\"click\", async (e) => {\n+            if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+              const isValid = await this.validate();\n+              if (isValid) {\n+                const saveResult = await this.submit();\n+                if (saveResult.redirect_url) {\n+                  window.location.pathname = saveResult.redirect_url;\n+                }\n+              }\n+            }\n+          });\n \n-        fieldElement.addEventListener(\"click\", async (e) => {\n-          if (e.detail.type === \"button\" && e.detail.value === \"submit\") {\n+          fieldElement.addEventListener(\"submit\", async (e) => {\n             const isValid = await this.validate();\n             if (isValid) {\n               const saveResult = await this.submit();\n@@ -325,20 +363,22 @@ class GenericForm extends HTMLElement {\n                 window.location.pathname = saveResult.redirect_url;\n               }\n             }\n-          }\n-        });\n-\n-        fieldElement.addEventListener(\"submit\", async (e) => {\n-          const isValid = await this.validate();\n-          if (isValid) {\n-            const saveResult = await this.submit();\n-            if (saveResult.redirect_url) {\n-              window.location.pathname = saveResult.redirect_url;\n-            }\n-          }\n-        })\n+          })\n+        }\n       });\n+    } catch (error) {\n+      this.container.textContent = `Error: ${error.message}`;\n+    }\n+  }\n+\n+  async loadForm(url) {\n+    try {\n+      const response = await fetch(url);\n+      if (!response.ok) {\n+        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);\n+      }\n \n+      await this.constructForm(await response.json());\n     } catch (error) {\n       this.container.textContent = `Error: ${error.message}`;\n     }\ndiff --git a/src/snek/templates/login.html b/src/snek/templates/login.html\nindex ed81224..d91920c 100644\n--- a/src/snek/templates/login.html\n+++ b/src/snek/templates/login.html\n@@ -11,6 +11,6 @@\n {% block main %}\n     <div class=\"back-form\">\n         <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n-        <generic-form class=\"center\" url=\"/login.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/login.json\" preloaded-structure='{{ form|tojson|safe }}'></generic-form>\n     </div>\n {% endblock %}\ndiff --git a/src/snek/templates/register.html b/src/snek/templates/register.html\nindex 2fa89d3..21f8fe0 100644\n--- a/src/snek/templates/register.html\n+++ b/src/snek/templates/register.html\n@@ -12,6 +12,6 @@\n     <div class=\"back-form\">\n         <fancy-button url=\"/back\" text=\"Back\" size=\"auto\"></fancy-button>\n \n-        <generic-form class=\"center\" url=\"/register.json\"></generic-form>\n+        <generic-form class=\"center\" url=\"/register.json\" preloaded-structure='{{ form|tojson|safe }}'></generic-form>\n     </div>\n {% endblock %}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 580655f..6d6d6ad 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -18,7 +18,7 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\")\n+        return await self.render_template(\"login.html\", {\"form\": await self.form(app=self.app).to_json()})\n \n     async def submit(self, form):\n         if await form.is_valid:\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex fdcc9ad..db812b5 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -18,7 +18,7 @@ class RegisterView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"register.html\")\n+        return await self.render_template(\"register.html\", {\"form\": await self.form(app=self.app).to_json()})\n \n     async def submit(self, form):\n         result = await self.app.services.user.register("}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected semicolon in loadForm call", "commit": "fd07001983fc3d3015ac7064461c14b8486155e6", "diff": "commit fd07001983fc3d3015ac7064461c14b8486155e6\nAuthor: BordedDev <>\nDate:   Sat Mar 8 21:18:20 2025 +0100\n\n    Returned a semi\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 73e55cb..319d7d3 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -304,7 +304,7 @@ class GenericForm extends HTMLElement {\n       if (!url.startsWith(\"/\")) {\n         fullUrl.searchParams.set('url', url);\n       }\n-      this.loadForm(fullUrl.toString())\n+      this.loadForm(fullUrl.toString());\n     } else {\n       this.container.textContent = \"No URL provided!\";\n     }"}
{"repo": ".", "date": "2025-03-09", "line": "feat: Preload form and autofocus first input", "commit": "d9ac1813ba8ddad9fb602730cb2cc763aab4bc23", "diff": "commit d9ac1813ba8ddad9fb602730cb2cc763aab4bc23\nMerge: 11b8f0e fd07001\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 9 18:37:34 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-09", "line": "fix: Sort threads by last message, handling missing timestamps", "commit": "91d8f3efd16431fe99b0e60927d1f6d9b6587f7e", "diff": "commit 91d8f3efd16431fe99b0e60927d1f6d9b6587f7e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 9 20:38:14 2025 +0100\n\n    Added alter.\n\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 14431f1..4f3fe8c 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -28,6 +28,6 @@ class ThreadsView(BaseView):\n             thread['last_message_user_color'] = user_last_message['color']  \n             threads.append(thread)\n             \n-        threads.sort(key=lambda x: x['last_message_on'], reverse=True)\n+        threads.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n         return await self.render_template(\"threads.html\", dict(threads=threads,user=user))"}
{"repo": ".", "date": "2025-03-10", "line": "fix: Improved search user page layout", "commit": "c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8", "diff": "commit c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 10 11:53:54 2025 +0100\n\n    List fix.\n\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 842b982..453f7e3 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -8,7 +8,7 @@\n     <div class=\"chat-header\">\n         <h2>Search user</h2>\n     </div>\n-    <div class=\"container\">\n+    <div class=\"container chat-area\">\n         <form method=\"get\" action=\"/search-user.html\">\n             <input type=\"text\" placeholder=\"Username\" name=\"query\" value=\"{{query}}\" REQUIRED></input>\n             <fancy-button size=\"auto\" text=\"Search\" url=\"submit\"></fancy-button>"}
{"repo": ".", "date": "2025-03-11", "line": "feat: Improved file download and naming conventions", "commit": "c6c2766381f75b058fb61f91556788b0720b058b", "diff": "commit c6c2766381f75b058fb61f91556788b0720b058b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 11 10:10:50 2025 +0100\n\n    Added better file handling.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex f9bad33..8884d72 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -25,6 +25,7 @@ class UploadView(BaseView):\n         drive_item = await self.services.drive_item.get(uid)\n         response = web.FileResponse(drive_item[\"path\"])\n         response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n+        response.headers['Content-Disposition'] = f'attachment; filename=\"{drive_item[\"name\"]}\"'\n         return response\n \n     async def post(self):\n@@ -57,8 +58,10 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n+            \n+            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n-            file_path = pathlib.Path(UPLOAD_DIR).joinpath(filename.strip(\"/\").strip(\".\"))\n+            file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n             files.append(file_path)\n \n             async with aiofiles.open(str(file_path.absolute()), 'wb') as f:"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added reply functionality and improved time display", "commit": "5cfcafe0821b3cceb753b9ddb4076a79f26a88c0", "diff": "commit 5cfcafe0821b3cceb753b9ddb4076a79f26a88c0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:15:10 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 2c48cb4..cdf7672 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -22,6 +22,10 @@\n <script>\n     const channelUid = \"{{ channel.uid.value }}\";\n \n+    function getInputField(){\n+        return document.querySelector(\"textarea\")\n+    }\n+        \n     function initInputField(textBox) {\n         textBox.addEventListener('change', (e) => {\n             e.preventDefault();\n@@ -41,9 +45,28 @@\n         textBox.focus();\n     }\n \n+    function replyMessage(message) {\n+        const field = getInputField() \n+        field.value = \"```\\n\" + (message || '') + \"\\n```\\n\";\n+        field.focus();\n+    }\n+\n     function updateTimes() {\n-        document.querySelectorAll(\".time\").forEach((time) => {\n+        document.querySelectorAll(\".time\").forEach((container) => {\n+            const messageDiv = container.closest('.message');\n+            const userNick = messageDiv.dataset.user_nick;\n+            const text = messageDiv.querySelector(\".text\").innerText;\n+            const time = document.createElement(\"span\");\n             time.innerText = app.timeDescription(time.dataset.created_at);\n+            container.replaceChildren(time);\n+            const reply = document.createElement(\"a\");\n+            reply.innerText = \" reply\";\n+            container.appendChild(reply);\n+            reply.addEventListener('click', (e) => {\n+                e.preventDefault();\n+                replyMessage(text);\n+            })\n         });\n     }\n \n@@ -159,7 +182,7 @@\n         }, 1000);\n     });\n \n-    initInputField(document.querySelector(\"textarea\"));\n+    initInputField(getInputField());\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Display creation time on container", "commit": "c55927aa9c7e575544901bbee41cb9a9d3c6437a", "diff": "commit c55927aa9c7e575544901bbee41cb9a9d3c6437a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:21:38 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex cdf7672..d76e9f0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -57,7 +57,8 @@\n             const userNick = messageDiv.dataset.user_nick;\n             const text = messageDiv.querySelector(\".text\").innerText;\n             const time = document.createElement(\"span\");\n-            time.innerText = app.timeDescription(time.dataset.created_at);\n+            time.innerText = app.timeDescription(container.dataset.created_at);\n+            \n             container.replaceChildren(time);\n             const reply = document.createElement(\"a\");\n             reply.innerText = \" reply\";"}
{"repo": ".", "date": "2025-03-13", "line": "style: Darkened the overall theme.", "commit": "0fad298fc078e2a3ea1afee71ee92b99b83427b0", "diff": "commit 0fad298fc078e2a3ea1afee71ee92b99b83427b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:51:01 2025 +0100\n\n    Blacked.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 3d154a9..b683324 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -20,7 +20,7 @@\n \n body {\n   font-family: Arial, sans-serif;\n   line-height: 1.5;\n   display: flex;\n@@ -36,7 +36,7 @@ main {\n }\n \n header {\n   padding: 10px 20px;\n   display: flex;\n   justify-content: space-between;\n@@ -78,14 +78,13 @@ a {\n   flex: 1;\n   display: flex;\n   flex-direction: column;\n   overflow: hidden;\n }\n \n .chat-header {\n   padding: 10px 20px;\n   user-select: none;\n }\n \n@@ -109,7 +108,7 @@ a {\n   -ms-overflow-style: none;\n   padding: 10px;\n   height: 200px;\n }\n \n .container {\n@@ -205,15 +204,14 @@ a {\n \n .chat-input {\n   padding: 15px;\n   display: flex;\n   align-items: center;\n }\n \n input[type=\"text\"], .chat-input textarea {\n   flex: 1;\n   color: white;\n   border: none;\n   padding: 10px;\n@@ -312,10 +310,10 @@ a {\n \n .sidebar {\n   width: 250px;\n-  padding: 20px;\n+  padding-left: 20px;\n+  padding-right: 20px;\n   overflow-y: auto;\n }\n \n .sidebar h2 {"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added padding to links", "commit": "0f950218d6d783c4738966e15b2746c14043c82f", "diff": "commit 0f950218d6d783c4738966e15b2746c14043c82f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 19:52:03 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex b683324..448cb28 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -313,6 +313,7 @@ a {\n   padding-left: 20px;\n   padding-right: 20px;\n+  padding-top: 10px;\n   overflow-y: auto;\n }"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Improved reply formatting with markdown and blockquote", "commit": "d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139", "diff": "commit d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 13 20:36:14 2025 +0100\n\n    Added reply.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d76e9f0..a6efee3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,7 +47,7 @@\n \n     function replyMessage(message) {\n         const field = getInputField() \n-        field.value = \"```\\n\" + (message || '') + \"\\n```\\n\";\n+        field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }"}
{"repo": ".", "date": "2025-03-14", "line": "feat: Increased update interval for times", "commit": "17c9731b9f8be07b247aeed29ba2ab1319e408f0", "diff": "commit 17c9731b9f8be07b247aeed29ba2ab1319e408f0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 14 23:02:58 2025 +0100\n\n    Done stuff.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex a6efee3..e58af53 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -138,7 +138,7 @@\n         }\n     }\n \n-    setInterval(updateTimes, 1000);\n+    setInterval(updateTimes, 30000);\n \n     function isMentionToMe(message){\n         const mentionText = '@{{ user.username.value }}';"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Implemented shared input styling and CSS imports", "commit": "5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621", "diff": "commit 5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621\nAuthor: BordedDev <>\nDate:   Sat Mar 15 15:53:24 2025 +0100\n\n    Added updated input styling to other pages\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 448cb28..04610da 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -1,3 +1,5 @@\n+@import \"shared.css\";\n+\n * {\n   margin: 0;\n   box-sizing: border-box;\ndiff --git a/src/snek/static/shared.css b/src/snek/static/shared.css\nnew file mode 100644\nindex 0000000..d7a652b\n--- /dev/null\n+++ b/src/snek/static/shared.css\n@@ -0,0 +1,15 @@\n+\n+\n+input, textarea {\n+  &:focus {\n+  }\n+\n+  &::placeholder {\n+    transition: opacity 0.3s;\n+  }\n+\n+  &:focus::placeholder {\n+    opacity: 0.4;\n+  }\n+}\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 63a28ed..0661225 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -1,3 +1,4 @@\n+@import \"shared.css\";\n \n * {"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Applied updated input styling across pages", "commit": "752f3df13a548d22646f87a87940dc64e15587f3", "diff": "commit 752f3df13a548d22646f87a87940dc64e15587f3\nMerge: 17c9731 5b70bb9\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sat Mar 15 15:27:38 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor app modules and update script tags to use type=\"module\"", "commit": "a4d79b06c49ec9f605336bf181e98455c8acd460", "diff": "commit a4d79b06c49ec9f605336bf181e98455c8acd460\nAuthor: BordedDev <>\nDate:   Sat Mar 15 19:10:52 2025 +0100\n\n    Updated files to support modules\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex f26919e..29aedb2 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,7 +7,9 @@\n \n \n-class RESTClient {\n+import { Schedule } from './schedule.js';\n+\n+export class RESTClient {\n     debug = false;\n \n     async get(url, params = {}) {\n@@ -43,7 +45,7 @@ class RESTClient {\n     }\n }\n \n-class EventHandler {\n+export class EventHandler {\n     constructor() {\n         this.subscribers = {};\n     }\n@@ -58,7 +60,7 @@ class EventHandler {\n     }\n }\n \n-class Chat extends EventHandler {\n+export class Chat extends EventHandler {\n     constructor() {\n         super();\n@@ -132,7 +134,7 @@ class Chat extends EventHandler {\n     }\n }\n \n-class Socket extends EventHandler {\n+export class Socket extends EventHandler {\n     ws = null;\n     isConnected = null;\n     isConnecting = null;\n@@ -259,7 +261,7 @@ class Socket extends EventHandler {\n     }\n }\n \n-class NotificationAudio {\n+export class NotificationAudio {\n     constructor(timeout = 500) {\n         this.schedule = new Schedule(timeout);\n     }\n@@ -284,7 +286,7 @@ class NotificationAudio {\n     }\n }\n \n-class App extends EventHandler {\n+export class App extends EventHandler {\n     rest = new RESTClient();\n     ws = null;\n     rpc = null;\n@@ -366,4 +368,4 @@ class App extends EventHandler {\n     }\n }\n \n-const app = new App();\n+export const app = new App();\ndiff --git a/src/snek/static/schedule.js b/src/snek/static/schedule.js\nindex 36ae803..7e3add5 100644\n--- a/src/snek/static/schedule.js\n+++ b/src/snek/static/schedule.js\n@@ -9,7 +9,7 @@\n \n-class Schedule {\n+export class Schedule {\n   constructor(msDelay = 100) {\n     this.msDelay = msDelay;\n     this._once = false;\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 60109de..5e30845 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -9,12 +9,11 @@\n   <!-- \n   <script src=\"/push.js\"></script>\n   -->\n-  <script src=\"/fancy-button.js\"></script>\n-  <script src=\"/upload-button.js\"></script>\n-  <script src=\"/generic-form.js\"></script>\n-  <script src=\"/html-frame.js\"></script>\n-  <script src=\"/schedule.js\"></script>\n-  <script src=\"/app.js\"></script>\n+  <script src=\"/fancy-button.js\" type=\"module\"></script>\n+  <script src=\"/upload-button.js\" type=\"module\"></script>\n+  <script src=\"/generic-form.js\" type=\"module\"></script>\n+  <script src=\"/html-frame.js\" type=\"module\"></script>\n+  <script src=\"/app.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n \n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d93b568..a7cc1c5 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -12,13 +12,13 @@\n \n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n \n-    <script src=\"/app.js\"></script>\n-    <script src=\"/message-list.js\"></script>\n+    <script src=\"/app.js\"  type=\"module\"></script>\n+    <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n     <link rel=\"stylesheet\" href=\"/style.css\">\n-    <script src=\"/fancy-button.js\"></script>\n-    <script src=\"/html-frame.js\"></script>\n-    <script src=\"/generic-form.js\"></script>\n+    <script src=\"/fancy-button.js\" type=\"module\"></script>\n+    <script src=\"/html-frame.js\" type=\"module\"></script>\n+    <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n \n     {% block head %}\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 453f7e3..cf35d0d 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -35,7 +35,8 @@\n \n     </div>\n </section>\n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n \n     document.querySelector(\"[name=query]\").focus();\n \ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex 73c256a..ae71c6f 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -24,7 +24,8 @@\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n </section>\n \n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n \n     function updateTimes() {\n         document.querySelectorAll(\".time\").forEach((time) => {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e58af53..e1e443b 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -19,7 +19,9 @@\n     </div>\n </section>\n \n-<script>\n+<script type=\"module\">\n+    import { app } from \"/app.js\";\n+\n     const channelUid = \"{{ channel.uid.value }}\";\n \n     function getInputField(){\n@@ -120,6 +122,8 @@\n         loadExtra();\n     });\n \n+    let lastMessage\n+\n     function updateLayout(doScrollDown) {\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         updateTimes();\n@@ -134,7 +138,7 @@\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n-            lastMessage.scrollIntoView({ inline: \"nearest\" });\n+            lastMessage?.scrollIntoView({ inline: \"nearest\" });\n         }\n     }\n \n@@ -171,7 +175,7 @@\n         }\n         \n         const messagesContainer = document.querySelector(\".chat-messages\");\n-        const lastMessage = messagesContainer.querySelector(\".message:last-child\"); \n+        lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);\n \n         const message = document.createElement(\"div\");"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Convert files to modules", "commit": "a9663c8170dd2f925100eaa50e8c0019c5eee683", "diff": "commit a9663c8170dd2f925100eaa50e8c0019c5eee683\nMerge: 752f3df a4d79b0\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 16 01:40:06 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Refactor socket connection and event handling", "commit": "4a8a614adb5e15cad18414214d30ab83464eae14", "diff": "commit 4a8a614adb5e15cad18414214d30ab83464eae14\nAuthor: BordedDev <>\nDate:   Sun Mar 16 04:55:01 2025 +0100\n\n    Replaced socket code\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 29aedb2..e4a59ea 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,7 +7,9 @@\n \n \n-import { Schedule } from './schedule.js';\n+import {Schedule} from './schedule.js';\n+import {EventHandler} from \"./event-handler.js\";\n+import {Socket} from \"./socket.js\";\n \n export class RESTClient {\n     debug = false;\n@@ -23,7 +25,7 @@ export class RESTClient {\n         });\n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url, params, result });\n+            console.debug({url, params, result});\n         }\n         return result;\n     }\n@@ -39,27 +41,12 @@ export class RESTClient {\n \n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({ url, data, result });\n+            console.debug({url, data, result});\n         }\n         return result;\n     }\n }\n \n-export class EventHandler {\n-    constructor() {\n-        this.subscribers = {};\n-    }\n-\n-    addEventListener(type, handler) {\n-        if (!this.subscribers[type]) this.subscribers[type] = [];\n-        this.subscribers[type].push(handler);\n-    }\n-\n-    emit(type, ...data) {\n-        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n-    }\n-}\n-\n export class Chat extends EventHandler {\n     constructor() {\n         super();\n@@ -100,7 +87,7 @@ export class Chat extends EventHandler {\n     call(method, ...args) {\n         return new Promise((resolve, reject) => {\n             try {\n-                const command = { method, args, message_id: this.generateUniqueId() };\n+                const command = {method, args, message_id: this.generateUniqueId()};\n                 this._promises[command.message_id] = resolve;\n                 this._socket.send(JSON.stringify(command));\n             } catch (e) {\n@@ -134,133 +121,6 @@ export class Chat extends EventHandler {\n     }\n }\n \n-export class Socket extends EventHandler {\n-    ws = null;\n-    isConnected = null;\n-    isConnecting = null;\n-    url = null;\n-    connectPromises = [];\n-    ensureTimer = null;\n-\n-    constructor() {\n-        super();\n-        this.ensureConnection();\n-    }\n-\n-    _camelToSnake(str) {\n-        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n-    }\n-\n-    get client() {\n-        const me = this;\n-        return new Proxy({}, {\n-            get(_, prop) {\n-                return (...args) => {\n-                    const functionName = me._camelToSnake(prop);\n-                    return me.call(functionName, ...args);\n-                };\n-            },\n-        });\n-    }\n-\n-    ensureConnection() {\n-        if (this.ensureTimer) {\n-            return this.connect();\n-        }\n-        const me = this;\n-        this.ensureTimer = setInterval(() => {\n-            if (me.isConnecting) me.isConnecting = false;\n-            me.connect();\n-        }, 5000);\n-        return this.connect();\n-    }\n-\n-    generateUniqueId() {\n-        return 'id-' + Math.random().toString(36).substr(2, 9);\n-    }\n-\n-    connect() {\n-        \n-        const me = this \n-        if (this.isConnected || this.isConnecting) {\n-            return new Promise((resolve) => {\n-                if(me.isConnected)resolve(me)\n-                else if(me.isConnecting)\n-                me.connectPromises.push(resolve);\n-            });\n-        }\n-        this.isConnecting = true;\n-        return new Promise((resolve) => {\n-            this.connectPromises.push(resolve);\n-            console.debug(\"Connecting..\");\n-\n-            const ws = new WebSocket(this.url);\n-            ws.onopen = () => {\n-                this.ws = ws;\n-                this.isConnected = true;\n-                this.isConnecting = false;\n-                ws.onmessage = (event) => {\n-                    this.onData(JSON.parse(event.data));\n-                };\n-                ws.onclose = () => {\n-                    this.onClose();\n-                };\n-                ws.onerror = () => {\n-                    this.onClose();\n-                };\n-                this.onConnect()\n-                this.connectPromises.forEach(resolver => resolver(this));\n-            };\n-        });\n-    }\n-    onConnect(){\n-        this.emit(\"connected\")\n-    }\n-    onData(data) {\n-        if (data.success !== undefined && !data.success) {\n-            console.error(data);\n-        }\n-        if (data.callId) {\n-            this.emit(data.callId, data.data);\n-        }\n-        if (data.channel_uid) {\n-            this.emit(data.channel_uid, data.data);\n-            this.emit(\"channel-message\", data);\n-        }\n-    }\n-\n-    async sendJson(data) {\n-        await this.connect().then(api => {\n-            api.ws.send(JSON.stringify(data));\n-        });\n-    }\n-\n-    async call(method, ...args) {\n-        const call = {\n-            callId: this.generateUniqueId(),\n-            method,\n-            args,\n-        };\n-        const me = this \n-        return new Promise((resolve) => {\n-            me.addEventListener(call.callId, data => resolve(data));\n-            me.sendJson(call);\n-        });\n-    }\n-\n-    onClose() {\n-        console.info(\"Connection lost. Reconnecting.\");\n-        this.isConnected = false;\n-        this.isConnecting = false;\n-        this.ws.close();\n-        this.ws = null;\n-        this.ensureConnection().then(() => {\n-            console.info(\"Reconnected.\");\n-        });\n-    }\n-}\n-\n export class NotificationAudio {\n     constructor(timeout = 500) {\n         this.schedule = new Schedule(timeout);\n@@ -268,9 +128,9 @@ export class NotificationAudio {\n \n     sounds = {\n         \"message\": \"/audio/soundfx.d_beep3.mp3\",\n-        \"mention\":  \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n-        \"messageOtherChannel\":  \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n-        \"ping\":  \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n+        \"mention\": \"/audio/750607__deadrobotmusic__notification-sound-1.wav\",\n+        \"messageOtherChannel\": \"/audio/750608__deadrobotmusic__notification-sound-2.wav\",\n+        \"ping\": \"/audio/750609__deadrobotmusic__notification-sound-3.wav\",\n     }\n \n     play(soundIndex = 0) {\n@@ -294,11 +154,12 @@ export class App extends EventHandler {\n     user = {};\n \n     async ping(...args) {\n-        if(this.is_pinging)return false \n+        if (this.is_pinging) return false\n         this.is_pinging = true\n         await this.rpc.ping(...args);\n         this.is_pinging = false\n     }\n+\n     async forcePing(...arg) {\n         await this.rpc.ping(...args);\n     }\n@@ -308,14 +169,14 @@ export class App extends EventHandler {\n         this.ws = new Socket();\n         this.rpc = this.ws.client;\n         this.audio = new NotificationAudio(500);\n-        this.is_pinging = false \n-        this.ping_interval = setInterval(()=>{\n+        this.is_pinging = false\n+        this.ping_interval = setInterval(() => {\n             this.ping(\"active\")\n         }, 15000)\n-        \n \n-        const me = this \n-        this.ws.addEventListener(\"connected\", (data)=> {\n+\n+        const me = this\n+        this.ws.addEventListener(\"connected\", (data) => {\n             this.ping(\"online\")\n         })\n         this.ws.addEventListener(\"channel-message\", (data) => {\n@@ -330,6 +191,7 @@ export class App extends EventHandler {\n     playSound(index) {\n         this.audio.play(index);\n     }\n+\n     timeDescription(isoDate) {\n         const date = new Date(isoDate);\n         const hours = String(date.getHours()).padStart(2, \"0\");\n@@ -337,6 +199,7 @@ export class App extends EventHandler {\n         let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;\n         return timeStr;\n     }\n+\n     timeAgo(date1, date2) {\n         const diffMs = Math.abs(date2 - date1);\n         const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n@@ -355,9 +218,10 @@ export class App extends EventHandler {\n         }\n         return 'just now';\n     }\n+\n     async benchMark(times = 100, message = \"Benchmark Message\") {\n         const promises = [];\n-        const me = this; \n+        const me = this;\n         for (let i = 0; i < times; i++) {\n             promises.push(this.rpc.getChannels().then(channels => {\n                 channels.forEach(channel => {\n@@ -369,3 +233,4 @@ export class App extends EventHandler {\n }\n \n export const app = new App();\n+window.app = app;\n\\ No newline at end of file\ndiff --git a/src/snek/static/event-handler.js b/src/snek/static/event-handler.js\nnew file mode 100644\nindex 0000000..a6d00e4\n--- /dev/null\n+++ b/src/snek/static/event-handler.js\n@@ -0,0 +1,16 @@\n+\n+\n+export class EventHandler {\n+    constructor() {\n+        this.subscribers = {};\n+    }\n+\n+    addEventListener(type, handler) {\n+        if (!this.subscribers[type]) this.subscribers[type] = [];\n+        this.subscribers[type].push(handler);\n+    }\n+\n+    emit(type, ...data) {\n+        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));\n+    }\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/static/socket.js b/src/snek/static/socket.js\nnew file mode 100644\nindex 0000000..83f6cac\n--- /dev/null\n+++ b/src/snek/static/socket.js\n@@ -0,0 +1,137 @@\n+import {EventHandler} from \"./event-handler.js\";\n+\n+export class Socket extends EventHandler {\n+     * @type {URL}\n+    url\n+     * @type {WebSocket|null}\n+    ws = null\n+\n+     * @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}\n+    connection = null\n+\n+    shouldReconnect = true;\n+\n+    get isConnected() {\n+        return this.ws && this.ws.readyState === WebSocket.OPEN;\n+    }\n+\n+    get isConnecting() {\n+        return this.ws && this.ws.readyState === WebSocket.CONNECTING;\n+    }\n+\n+    constructor() {\n+        super();\n+\n+        this.url = new URL('/rpc.ws', window.location.origin);\n+        this.url.protocol = this.url.protocol.replace('http', 'ws');\n+\n+        this.connect()\n+    }\n+\n+    connect() {\n+        if (this.ws) {\n+            return this.connection.promise;\n+        }\n+\n+        if (!this.connection || this.connection.resolved) {\n+            this.connection = Promise.withResolvers()\n+        }\n+\n+        this.ws = new WebSocket(this.url);\n+        this.ws.addEventListener(\"open\", () => {\n+            this.connection.resolved = true;\n+            this.connection.resolve(this);\n+            this.emit(\"connected\");\n+        });\n+\n+        this.ws.addEventListener(\"close\", () => {\n+            console.log(\"Connection closed\");\n+            this.disconnect()\n+        })\n+        this.ws.addEventListener(\"error\", (e) => {\n+            console.error(\"Connection error\", e);\n+            this.disconnect()\n+        })\n+        this.ws.addEventListener(\"message\", (e) => {\n+            if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {\n+                console.error(\"Binary data not supported\");\n+            } else {\n+                try {\n+                    this.onData(JSON.parse(e.data));\n+                } catch (e) {\n+                    console.error(\"Failed to parse message\", e);\n+                }\n+            }\n+        })\n+    }\n+\n+\n+    onData(data) {\n+        if (data.success !== undefined && !data.success) {\n+            console.error(data);\n+        }\n+        if (data.callId) {\n+            this.emit(data.callId, data.data);\n+        }\n+        if (data.channel_uid) {\n+            this.emit(data.channel_uid, data.data);\n+            this.emit(\"channel-message\", data);\n+        }\n+    }\n+\n+    disconnect() {\n+        this.ws?.close();\n+        this.ws = null;\n+\n+        if (this.shouldReconnect) setTimeout(() => {\n+            console.log(\"Reconnecting\");\n+            return this.connect();\n+        }, 0);\n+    }\n+\n+\n+    _camelToSnake(str) {\n+        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();\n+    }\n+\n+    get client() {\n+        const me = this;\n+        return new Proxy({}, {\n+            get(_, prop) {\n+                return (...args) => {\n+                    const functionName = me._camelToSnake(prop);\n+                    return me.call(functionName, ...args);\n+                };\n+            },\n+        });\n+    }\n+\n+    generateCallId() {\n+        return self.crypto.randomUUID();\n+    }\n+\n+    async sendJson(data) {\n+        await this.connect().then(api => {\n+            api.ws.send(JSON.stringify(data));\n+        });\n+    }\n+\n+    async call(method, ...args) {\n+        const call = {\n+            callId: this.generateCallId(),\n+            method,\n+            args,\n+        };\n+        const me = this\n+        return new Promise((resolve) => {\n+            me.addEventListener(call.callId, data => resolve(data));\n+            me.sendJson(call);\n+        });\n+    }\n+}\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-03-16", "line": "refactor: Improved code formatting and consistency", "commit": "c9c070c497bb9af3eb5bb9915f221ec00b56b832", "diff": "commit c9c070c497bb9af3eb5bb9915f221ec00b56b832\nAuthor: BordedDev <>\nDate:   Sun Mar 16 05:03:05 2025 +0100\n\n    Reformated file\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex e4a59ea..95e5bc4 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -7,9 +7,9 @@\n \n \n-import {Schedule} from './schedule.js';\n-import {EventHandler} from \"./event-handler.js\";\n-import {Socket} from \"./socket.js\";\n+import { Schedule } from './schedule.js';\n+import { EventHandler } from \"./event-handler.js\";\n+import { Socket } from \"./socket.js\";\n \n export class RESTClient {\n     debug = false;\n@@ -25,7 +25,7 @@ export class RESTClient {\n         });\n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({url, params, result});\n+            console.debug({ url, params, result });\n         }\n         return result;\n     }\n@@ -41,7 +41,7 @@ export class RESTClient {\n \n         const result = await response.json();\n         if (this.debug) {\n-            console.debug({url, data, result});\n+            console.debug({ url, data, result });\n         }\n         return result;\n     }\n@@ -87,7 +87,7 @@ export class Chat extends EventHandler {\n     call(method, ...args) {\n         return new Promise((resolve, reject) => {\n             try {\n-                const command = {method, args, message_id: this.generateUniqueId()};\n+                const command = { method, args, message_id: this.generateUniqueId() };\n                 this._promises[command.message_id] = resolve;\n                 this._socket.send(JSON.stringify(command));\n             } catch (e) {"}
{"repo": ".", "date": "2025-03-16", "line": "fix: Reconnected socket on error", "commit": "e62a8554090009c7914b95833066ad46251da01d", "diff": "commit e62a8554090009c7914b95833066ad46251da01d\nMerge: a9663c8 c9c070c\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Sun Mar 16 05:02:03 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Changed button colors to black", "commit": "819cf8381c287e2ee88fb1d6fe789e30a1a33eff", "diff": "commit 819cf8381c287e2ee88fb1d6fe789e30a1a33eff\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 06:42:03 2025 +0100\n\n    Change color.\n\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex c399d2b..0a84705 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -60,7 +60,7 @@ class UploadButtonElement extends HTMLElement {\n                 justify-content: center;\n                 align-items: center;\n                 height: 100vh;\n             }\n             .upload-container {\n                 position: relative;\n@@ -70,7 +70,7 @@ class UploadButtonElement extends HTMLElement {\n                 align-items: center;\n                 justify-content: center;\n                 padding: 10px 20px;\n                 color: white;\n                 border: none;\n                 border-radius: 5px;\n@@ -87,7 +87,7 @@ class UploadButtonElement extends HTMLElement {\n                 left: 0;\n                 top: 0;\n                 height: 100%;\n-                background: rgba(255, 255, 255, 0.4);\n                 width: 0%;\n             }\n             .hidden-input {"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted upload button background color", "commit": "2ba28c193a826e8c1f9647f06bffb6084166c9f1", "diff": "commit 2ba28c193a826e8c1f9647f06bffb6084166c9f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 06:45:31 2025 +0100\n\n    Change color.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 04610da..943e819 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -222,7 +222,6 @@ input[type=\"text\"], .chat-input textarea {\n }\n \n .chat-input upload-button {\n   color: white;\n   border: none;\n   padding: 10px 15px;"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Added Umami analytics tracking", "commit": "287e10d8aa8feb2590ad48d9412ace74a9432baf", "diff": "commit 287e10d8aa8feb2590ad48d9412ace74a9432baf\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 16 19:46:15 2025 +0100\n\n    Added tracker.\n\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 5e30845..31f5d7f 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -17,6 +17,7 @@\n   <link rel=\"stylesheet\" href=\"/base.css\">\n \n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\n <body>\n   <header>\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex a7cc1c5..df0796c 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -20,7 +20,7 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n-\n     {% block head %}\n     {% endblock %}\n </head>\n@@ -35,4 +35,4 @@\n     {% endblock %}\n </main>\n </body>\n-</html>\n\\ No newline at end of file\n+</html>\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex 1894bc4..ffbd5bc 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -7,6 +7,7 @@\n     <link rel=\"stylesheet\" href=\"generic-form.css\">\n   <link rel=\"stylesheet\" href=\"register__.css\">\n     <script src=\"/fancy-button.js\"></script>\n </head>\n <body>\n   <div class=\"registration-container\">"}
{"repo": ".", "date": "2025-03-17", "line": "fix: Scroll to the end of the message container", "commit": "54416ee84f88064897a824ae2c3a9e0ef2c1ccaa", "diff": "commit 54416ee84f88064897a824ae2c3a9e0ef2c1ccaa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 17 09:15:49 2025 +0100\n\n    Added block end.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e1e443b..932a1fa 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -138,7 +138,7 @@\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n-            lastMessage?.scrollIntoView({ inline: \"nearest\" });\n+            lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Refactor header layout and logo display", "commit": "39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6", "diff": "commit 39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Mar 17 17:01:01 2025 +0100\n\n    Layout update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 943e819..0b475d1 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -39,16 +39,19 @@ main {\n \n header {\n-  padding: 10px 20px;\n+  padding-top: 10px;\n+  padding-left: 20px;\n+  padding-right: 20px;\n   display: flex;\n   justify-content: space-between;\n   align-items: center;\n }\n \n header .logo {\n-  font-size: 1.5em;\n   font-weight: bold;\n+ font-size: 1.2em;\n }\n \n header nav a {\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 31f5d7f..a8bb473 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -21,7 +21,9 @@\n </head>\n <body>\n   <header>\n-    <div class=\"no-select logo\">Snek</div>\n+    <div class=\"no-select logo\" style=\"display:none\">Snek</div>\n+    \n+    <div class=\"logo\">{% block header_text %}{% endblock %}</div>\n     <nav class=\"no-select\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n@@ -30,6 +32,7 @@\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n+\n   </header>\n   <main>\n     {% block sidebar %}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 932a1fa..8a68f64 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,10 +1,9 @@\n {% extends \"app.html\" %}\n \n+{% block header_text %}<h2>{{ name }}</h2>{% endblock %} \n+\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n-    <div class=\"chat-header\">\n-        <h2>{{ name }}</h2>\n-    </div>\n     <div class=\"chat-messages\">\n         {% for message in messages %}\n             {% autoescape false %}"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill and update templates", "commit": "7fa2817f773b47737236f4bb700023bcf8b483f1", "diff": "commit 7fa2817f773b47737236f4bb700023bcf8b483f1\nAuthor: BordedDev <>\nDate:   Mon Mar 17 22:05:46 2025 +0100\n\n    Added Promise.withResolvers pollyfill\n\ndiff --git a/src/snek/static/polyfills/Promise.withResolvers.js b/src/snek/static/polyfills/Promise.withResolvers.js\nnew file mode 100644\nindex 0000000..03f0185\n--- /dev/null\n+++ b/src/snek/static/polyfills/Promise.withResolvers.js\n@@ -0,0 +1,8 @@\n+Promise.withResolvers = Promise.withResolvers || function() {\n+    let resolve, reject;\n+    let promise = new Promise((res, rej) => {\n+        resolve = res;\n+        reject = rej;\n+    });\n+    return { promise, resolve, reject };\n+}\n\\ No newline at end of file\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 31f5d7f..2d96495 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -6,6 +6,7 @@\n     <link rel=\"manifest\" href=\"/manifest.json\" />\n   <title>Snek</title>\n   <style>{{highlight_styles}}</style>\n+  <script src=\"/polyfills/Promise.withResolvers.js\" type=\"module\"></script>\n   <!-- \n   <script src=\"/push.js\"></script>\n   -->\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex df0796c..d5df6eb 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -12,7 +12,8 @@\n \n     <title>{% block title %}Snek chat by Molodetz{% endblock %}</title>\n \n-    <script src=\"/app.js\"  type=\"module\"></script>\n+    <script src=\"/polyfills/Promise.withResolvers.js\" type=\"module\"></script>\n+    <script src=\"/app.js\" type=\"module\"></script>\n     <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n     <link rel=\"stylesheet\" href=\"/style.css\">\n@@ -20,7 +21,8 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n+            data-website-id=\"d127c3e4-dc70-4041-a1c8-bcc32c2492ea\"></script>\n     {% block head %}\n     {% endblock %}\n </head>"}
{"repo": ".", "date": "2025-03-17", "line": "revert: Removed unnecessary whitespace", "commit": "965dc930a900a5080e225bb492be2b799daed22f", "diff": "commit 965dc930a900a5080e225bb492be2b799daed22f\nAuthor: BordedDev <>\nDate:   Mon Mar 17 22:06:52 2025 +0100\n\n    Undid formatting\n\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d5df6eb..d2f8de7 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -21,8 +21,7 @@\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n     <script src=\"/generic-form.js\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n-            data-website-id=\"d127c3e4-dc70-4041-a1c8-bcc32c2492ea\"></script>\n     {% block head %}\n     {% endblock %}\n </head>"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill", "commit": "825ece4e7868be28ba03c4fde5149695b0dd9dc5", "diff": "commit 825ece4e7868be28ba03c4fde5149695b0dd9dc5\nMerge: 39fa8fa 965dc93\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Mon Mar 17 21:09:55 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Added dump script for public channels", "commit": "3c6a0944d68ca16250ec9364d7f006b0e7eea6e8", "diff": "commit 3c6a0944d68ca16250ec9364d7f006b0e7eea6e8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 00:46:32 2025 +0100\n\n    Added dump script.\n\ndiff --git a/Makefile b/Makefile\nindex e3febf7..70b06a0 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -8,6 +8,9 @@ PORT = 8081\n python:\n \t$(PYTHON)\n \n+dump:\n+\t$(PYTHON) -m snek.dump\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nnew file mode 100644\nindex 0000000..6401637\n--- /dev/null\n+++ b/src/snek/dump.py\n@@ -0,0 +1,17 @@\n+import json \n+\n+from snek.app import app\n+\n+\n+def dump_public_channels():\n+    result = {'channels':{}}\n+\n+    for channel in app.db['channel'].find(is_private=False,is_listed=True):\n+        result['channels'][channel['label']] = dict(channel)\n+        result['channels'][channel['label']]['messages'] = list(dict(record) for record in app.db['channel_message'].find(channel_uid=channel['uid']))\n+\n+    print(json.dumps(result, sort_keys=True, indent=4,default=str),end='',flush=True)\n+    \n+\n+if __name__ == '__main__':\n+    dump_public_channels()"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Improved dump functionality with JSON output and user information\n\n", "commit": "70db15bf27c7a8fd8bf112432f02754f01bbb3d7", "diff": "commit 70db15bf27c7a8fd8bf112432f02754f01bbb3d7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 00:13:25 2025 +0000\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex 70b06a0..62d24ca 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -9,7 +9,7 @@ python:\n \t$(PYTHON)\n \n dump:\n-\t$(PYTHON) -m snek.dump\n+\t@$(PYTHON) -m snek.dump\n \n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 6401637..cf3d417 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,17 +1,33 @@\n+import asyncio\n import json \n \n from snek.app import app\n \n+async def fix_message(message):\n+    message = dict(\n+        uid=message['uid'],\n+        user_uid=message['user_uid'],\n+        text=message['message'],\n+        sent=message['created_at']\n+    )\n+    user = await app.services.user.get(uid=message['user_uid'])\n+    message['user'] = user and user['username'] or None\n+    return message\n \n-def dump_public_channels():\n+async def dump_public_channels():\n     result = {'channels':{}}\n-\n-    for channel in app.db['channel'].find(is_private=False,is_listed=True):\n+    for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n+        print(f\"Dumping channel: {channel['label']}.\")\n         result['channels'][channel['label']] = dict(channel)\n-        result['channels'][channel['label']]['messages'] = list(dict(record) for record in app.db['channel_message'].find(channel_uid=channel['uid']))\n-\n-    print(json.dumps(result, sort_keys=True, indent=4,default=str),end='',flush=True)\n+        result['channels'][channel['label']]['messages'] = [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        print(\"Dump succesfull!\")\n+    print(\"Converting to json.\")\n+    data = json.dumps(result, indent=4,default=str)\n+    print(\"Converting succesful, now writing to dump.json\")\n+    with open(\"dump.json\",\"w\") as f:\n+        f.write(data)\n+    print(\"Dump written to dump.json\")\n     \n \n if __name__ == '__main__':\n-    dump_public_channels()\n+    asyncio.run(dump_public_channels())\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex f9c4761..cd9484d 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -20,13 +20,13 @@ class Cache:\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n-            print(\"Cache miss!\", args, flush=True)\n             return None\n         self.lru.insert(0, args)\n         while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n-        print(\"Cache hit!\", args, flush=True)\n         return self.cache[args]\n \n     def json_default(self, value):\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n-            print(f\"Cache store! {len(self.lru)} items. New version:\", self.version, flush=True)\n \n     async def delete(self, args):\n         if args in self.cache:"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Refactor dump script to output to text file", "commit": "3960390ec45f427979ebd2b81c1a21666a47e71d", "diff": "commit 3960390ec45f427979ebd2b81c1a21666a47e71d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Mar 18 21:06:28 2025 +0000\n\n    Updated dump script.\n\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex cf3d417..1b7eb6b 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -12,20 +12,18 @@ async def fix_message(message):\n     )\n     user = await app.services.user.get(uid=message['user_uid'])\n     message['user'] = user and user['username'] or None\n-    return message\n+    return (message['user'] or '') + ': ' + (message['text'] or '')\n \n async def dump_public_channels():\n-    result = {'channels':{}}\n+    result = []\n     for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n         print(f\"Dumping channel: {channel['label']}.\")\n-        result['channels'][channel['label']] = dict(channel)\n-        result['channels'][channel['label']]['messages'] = [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        result += [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n         print(\"Dump succesfull!\")\n     print(\"Converting to json.\")\n-    data = json.dumps(result, indent=4,default=str)\n     print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.json\",\"w\") as f:\n-        f.write(data)\n+    with open(\"dump.txt\",\"w\") as f:\n+        f.write('\\n\\n'.join(result))\n     print(\"Dump written to dump.json\")"}
{"repo": ".", "date": "2025-03-20", "line": "refactor: Update session management for user registration", "commit": "5ba239caa8928a078362af0e6e2d1a4626bd508d", "diff": "commit 5ba239caa8928a078362af0e6e2d1a4626bd508d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 20 02:12:00 2025 +0100\n\n    Changes by r.\n\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 6d6d6ad..5028a7a 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -31,4 +31,4 @@ class LoginView(BaseFormView):\n                 \"color\": user[\"color\"]\n             })\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+        return {\"is_valid\": False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex db812b5..6e49506 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -24,8 +24,10 @@ class RegisterView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"username\"]\n-        self.request.session[\"logged_in\"] = True\n-        self.request.session[\"color\"] = result[\"color\"]\n+        self.request.session.update({\n+            \"uid\": result[\"uid\"],\n+            \"username\": result[\"username\"],\n+            \"logged_in\": True,\n+            \"color\": result[\"color\"]\n+        })\n         return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 858edad..5ce42a7 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -14,21 +14,20 @@\n \n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n-\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n@@ -36,8 +35,10 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session[\"uid\"] = result[\"uid\"]\n-        self.request.session[\"username\"] = result[\"username\"]\n-        self.request.session[\"logged_in\"] = True\n-\n+        self.request.session.update({\n+            \"uid\": result[\"uid\"],\n+            \"username\": result[\"username\"],\n+            \"logged_in\": True,\n+            \"color\": result[\"color\"]\n+        })\n         return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 9428f08..117942a 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -19,11 +19,10 @@\n \n-\n from snek.system.view import BaseView\n \n class StatusView(BaseView):"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and updated Dockerfile and compose.yml", "commit": "7dcabde2ed0ab699ea3033f7788381e85c352b97", "diff": "commit 7dcabde2ed0ab699ea3033f7788381e85c352b97\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 18:16:36 2025 +0100\n\n    Update.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex ffdc3d7..6ef9f83 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -13,6 +13,7 @@ RUN apk add --no-cache \\\n     libxext \\\n     libssl3 \\\n     ca-certificates \\\n+    docker \\\n     fontconfig \\\n     freetype \\\n     ttf-dejavu \\\ndiff --git a/compose.yml b/compose.yml\nindex 17bcf22..70d21ba 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -2,10 +2,14 @@ services:\n   snek:\n     build: .\n     restart: always\n+    privileged: true\n     ports:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n+      - /media/storage/snek/molodetz.nl/drive:/code/drive\n+      - /var/run/docker.sock:/var/run/docker.sock\n+      - /media/storage/snek/molodetz.nl/drive:/drive\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e1f74e2..a7bc195 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -37,7 +37,7 @@ from snek.view.upload import UploadView\n from snek.view.search_user import SearchUserView \n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n-\n+from snek.view.terminal import TerminalView, TerminalSocketView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -115,6 +115,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/rpc.ws\", RPCView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n         self.router.add_view(\"/threads.html\", ThreadsView)\n+        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n+        self.router.add_view(\"/terminal.html\", TerminalView)\n \n         self.add_subapp(\n             \"/docs\","}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and basic shell environment.", "commit": "013d4adce57f4afec5176bbdb5e2225d529ec3b7", "diff": "commit 013d4adce57f4afec5176bbdb5e2225d529ec3b7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 19:57:39 2025 +0100\n\n    Drive access.\n\ndiff --git a/Dockerfile b/Dockerfile\nindex 6ef9f83..d24c47d 100644\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -1,41 +1,10 @@\n-FROM surnet/alpine-wkhtmltopdf:3.21.2-0.12.6-full as wkhtmltopdf \n-FROM python:3.12.8-alpine3.21\n+FROM python:3.14.0a6-bookworm\n+RUN mkdir -p /code \n WORKDIR /code\n-ENV FLASK_APP=app.py\n-ENV FLASK_RUN_HOST=0.0.0.0\n-RUN apk add --no-cache gcc musl-dev linux-headers git\n-\n-RUN apk add --no-cache \\\n-    libstdc++ \\\n-    libx11 \\\n-    libxrender \\\n-    libxext \\\n-    libssl3 \\\n-    ca-certificates \\\n-    docker \\\n-    fontconfig \\\n-    freetype \\\n-    ttf-dejavu \\\n-    ttf-droid \\\n-    ttf-freefont \\\n-    ttf-liberation \\\n-  && apk add --no-cache --virtual .build-deps \\\n-    msttcorefonts-installer \\\n-  && update-ms-fonts \\\n-  && fc-cache -f \\\n-  && apk del .build-deps\n-COPY --from=wkhtmltopdf /bin/wkhtmltopdf /bin/wkhtmltopdf\n-COPY --from=wkhtmltopdf /bin/wkhtmltoimage /bin/wkhtmltoimage\n+RUN apt update && apt install build-essential docker -y     \n COPY pyproject.toml pyproject.toml \n COPY src src\n+RUN mkdir /drive\n RUN pip install --upgrade pip\n RUN pip install -e .\n-EXPOSE 8081\n \n-CMD [\"python\",\"-m\",\"snek.app\"]\ndiff --git a/compose.yml b/compose.yml\nindex 70d21ba..9d55d3b 100644\n--- a/compose.yml\n+++ b/compose.yml\n@@ -7,26 +7,12 @@ services:\n       - \"8081:8081\"\n     volumes:\n       - ./:/code\n-      - /media/storage/snek/molodetz.nl/drive:/code/drive\n+      - /media/storage/snek.molodetz.nl/drive:/code/drive\n+      - /media/storage/snek.molodetz.nl/drive:/drive\n       - /var/run/docker.sock:/var/run/docker.sock\n-      - /media/storage/snek/molodetz.nl/drive:/drive\n     environment:\n      - PYTHONDONTWRITEBYTECODE=1\n      - PYTHONUNBUFFERED=1\n     entrypoint: [\"gunicorn\", \"-w\", \"1\", \"-k\", \"aiohttp.worker.GunicornWebWorker\", \"snek.gunicorn:app\",\"--bind\",\"0.0.0.0:8081\"]\n-  snecssh:\n-    build:\n-      context: .\n-      dockerfile: DockerfileDrive\n-    restart: always\n-    ports:\n-      - \"2225:2225\"\n-    volumes:\n-      - ./:/code\n-    environment:\n-     - PYTHONDONTWRITEBYTECODE=\"1\"\n-     - PYTHONUNBUFFERED=\"1\"\n-    entrypoint: [\"python\",\"-m\",\"snekssh.app2\"]\n    \ndiff --git a/src/snek/scripts/chat.js b/src/snek/scripts/chat.js\nnew file mode 100644\nindex 0000000..a4d6782\n--- /dev/null\n+++ b/src/snek/scripts/chat.js\n@@ -0,0 +1,54 @@\n+const channelUid = \"{{ channel.uid.value }}\";\n+\n+function initInputField(textBox) {\n+    textBox.addEventListener('change', (e) => {\n+        e.preventDefault();\n+        this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n+    });\n+\n+    textBox.addEventListener('keydown', (e) => {\n+        if (e.key === 'Enter' && !e.shiftKey) {\n+            e.preventDefault();\n+            const message = e.target.value.trim();\n+            if (message) {\n+                app.rpc.sendMessage(channelUid, message);\n+                e.target.value = '';\n+            }\n+        }\n+    });\n+    textBox.focus();\n+}\n+\n+function updateTimes() {\n+    document.querySelectorAll(\".time\").forEach((time) => {\n+        time.innerText = app.timeDescription(time.dataset.created_at);\n+    });\n+}\n+\n+function isElementVisible(element) {\n+    const rect = element.getBoundingClientRect();\n+    return (\n+        rect.top >= 0 &&\n+        rect.left >= 0 &&\n+        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&\n+        rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n+    );\n+}\n+\n+const messagesContainer = document.querySelector(\".chat-messages\");\n+\n+let isLoadingExtra = false;\n+\n+messagesContainer.addEventListener(\"scroll\", () => {\n+    loadExtra();\n+});\n+\n+setInterval(updateTimes, 1000);\n+\n+app.addEventListener(\"channel-message\", (data) => {\n+    if (data.channel_uid !== channelUid) {\n+        if(!isMentionForSomeoneElse(data.message)){\n+            channelSidebar.notify(data);\n+        }\n+    }\n+});\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nnew file mode 100644\nindex 0000000..2d9341e\n--- /dev/null\n+++ b/src/snek/system/terminal.py\n@@ -0,0 +1,48 @@\n+import asyncio\n+import aiohttp\n+import aiohttp.web\n+import os\n+import pty\n+import shlex\n+import subprocess\n+import pathlib\n+\n+commands = {\n+    'alpine': 'docker run -it alpine /bin/sh',\n+    'r': 'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh',\n+}\n+\n+class TerminalSession:\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.process = subprocess.Popen(\n+            command.split(\" \"),\n+            stdin=self.slave,\n+            stdout=self.slave,\n+            stderr=self.slave,\n+            bufsize=0,\n+            universal_newlines=True\n+        )\n+\n+    async def read_output(self, ws):\n+        loop = asyncio.get_event_loop()\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1:\n+            return \n+        while True:\n+            try:\n+                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n+                if not data:\n+                    break\n+                try:\n+                except:\n+                    self.sockets.remove(ws)\n+            except Exception:\n+                break\n+\n+    async def write_input(self, data):\n+        os.write(self.master, data.encode())\n+\n+\ndiff --git a/src/snek/templates/static b/src/snek/templates/static\nnew file mode 120000\nindex 0000000..d9bc54d\n--- /dev/null\n+++ b/src/snek/templates/static\n@@ -0,0 +1 @@\n+../static/\n\\ No newline at end of file\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nnew file mode 100644\nindex 0000000..d9ad6d1\n--- /dev/null\n+++ b/src/snek/templates/terminal.html\n@@ -0,0 +1,47 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+Reboot\n+{% endblock %}\n+\n+{% block main %}\n+    <style>\n+    </style>\n+\n+  <div class=\"container\" id=\"terminal\"></div>\n+\n+    <script>\n+        const term = new Terminal({ cursorBlink: true });\n+        const fitAddon = new FitAddon.FitAddon();\n+        term.loadAddon(fitAddon);\n+        term.open(document.getElementById(\"terminal\"));\n+        fitAddon.fit();\n+\n+        window.addEventListener(\"resize\", () => fitAddon.fit());\n+        \n+        const schema = window.location.protocol === \"https:\" ? \"wss\" : \"ws\";\n+        const hostname = window.location.host;\n+\n+        const socket = new WebSocket(url);\n+\n+        socket.onopen = () => term.write(\"\\x1b[32mConnected to Molodetz\\x1b[0m\\r\\n\");\n+\n+        socket.onmessage = (event) => {\n+            const data = new Uint8Array(event.data);\n+            term.write(new TextDecoder().decode(data));\n+        };\n+\n+        term.onData(data => socket.send(new TextEncoder().encode(data)));\n+\n+        socket.onclose = () => term.write(\"\\r\\n\\x1b[31mConnection closed\\x1b[0m\\r\\n\");\n+    </script>\n+\n+\n+\n+{% endblock main %}\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nnew file mode 100644\nindex 0000000..e54dfb7\n--- /dev/null\n+++ b/src/snek/view/terminal.py\n@@ -0,0 +1,56 @@\n+from snek.system.view import BaseView \n+import aiohttp \n+import asyncio\n+from snek.system.terminal import TerminalSession\n+import pathlib\n+\n+class TerminalSocketView(BaseView):\n+    \n+    login_required = True\n+\n+    user_sessions = {}\n+    \n+    async def prepare_drive(self):\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n+        root.mkdir(parents=True, exist_ok=True)\n+        terminal_folder = pathlib.Path(\"terminal\")\n+        for path in terminal_folder.iterdir():\n+            destination_path = root.joinpath(path.name)\n+            if not destination_path.exists():\n+                if not path.is_dir():\n+                    destination_path.write_bytes(path.read_bytes())\n+        return root \n+        \n+    async def get(self):\n+       \n+\n+\n+        ws = aiohttp.web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = await self.prepare_drive()\n+        \n+        command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n+        print(command)\n+\n+        session = self.user_sessions.get(user[\"uid\"])\n+        if not session:\n+            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n+        session = self.user_sessions[user[\"uid\"]]   \n+        asyncio.create_task(session.read_output(ws))  \n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.BINARY:\n+                await session.write_input(msg.data.decode())\n+\n+        \n+        return ws\n+\n+class TerminalView(BaseView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        request = self.request\n+        return await self.request.app.render_template('terminal.html',self.request)\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nnew file mode 100644\nindex 0000000..2b5d791\n--- /dev/null\n+++ b/terminal/.bashrc\n@@ -0,0 +1,108 @@\n+\n+[ -z \"$PS1\" ] && return\n+\n+HISTCONTROL=ignoredups:ignorespace\n+\n+shopt -s histappend\n+\n+HISTSIZE=1000\n+HISTFILESIZE=2000\n+\n+shopt -s checkwinsize\n+\n+[ -x /usr/bin/lesspipe ] && eval \"$(SHELL=/bin/sh lesspipe)\"\n+\n+if [ -z \"$debian_chroot\" ] && [ -r /etc/debian_chroot ]; then\n+    debian_chroot=$(cat /etc/debian_chroot)\n+fi\n+\n+case \"$TERM\" in\n+    xterm-color) color_prompt=yes;;\n+esac\n+\n+\n+if [ -n \"$force_color_prompt\" ]; then\n+    if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then\n+\tcolor_prompt=yes\n+    else\n+\tcolor_prompt=\n+    fi\n+fi\n+\n+if [ \"$color_prompt\" = yes ]; then\n+    PS1='${debian_chroot:+($debian_chroot)}\\[\\033[01;32m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;34m\\]\\w\\[\\033[00m\\]\\$ '\n+else\n+    PS1='${debian_chroot:+($debian_chroot)}\\u@\\h:\\w\\$ '\n+fi\n+unset color_prompt force_color_prompt\n+\n+case \"$TERM\" in\n+xterm*|rxvt*)\n+    PS1=\"\\[\\e]0;${debian_chroot:+($debian_chroot)}\\u@\\h: \\w\\a\\]$PS1\"\n+    ;;\n+*)\n+    ;;\n+esac\n+\n+if [ -x /usr/bin/dircolors ]; then\n+    test -r ~/.dircolors && eval \"$(dircolors -b ~/.dircolors)\" || eval \"$(dircolors -b)\"\n+    alias ls='ls --color=auto'\n+\n+    alias grep='grep --color=auto'\n+    alias fgrep='fgrep --color=auto'\n+    alias egrep='egrep --color=auto'\n+fi\n+\n+alias ll='ls -alF'\n+alias la='ls -A'\n+alias l='ls -CF'\n+\n+\n+if [ -f ~/.bash_aliases ]; then\n+    . ~/.bash_aliases\n+fi\n+\n+\n+cp ~/r /usr/local/bin \n+\n+\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv -y\n+\n+echo \"r is installed.\"\n+\ndiff --git a/terminal/.profile b/terminal/.profile\nnew file mode 100644\nindex 0000000..c4c7402\n--- /dev/null\n+++ b/terminal/.profile\n@@ -0,0 +1,9 @@\n+\n+if [ \"$BASH\" ]; then\n+  if [ -f ~/.bashrc ]; then\n+    . ~/.bashrc\n+  fi\n+fi\n+\n+mesg n 2> /dev/null || true\ndiff --git a/terminal/r b/terminal/r\nnew file mode 100755\nindex 0000000..2cc65df\nBinary files /dev/null and b/terminal/r differ"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal page with Ubuntu option and channel sidebar", "commit": "6e68408ddfd8be2376f7453deac8f63a6bfb93e4", "diff": "commit 6e68408ddfd8be2376f7453deac8f63a6bfb93e4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:04:20 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 9dd20b5..2d034b7 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,6 +4,11 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2 class=\"no-select\">Terminals</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n+    </ul>\n+    {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n       {% for channel in channels if not channel['is_private'] %}\n@@ -16,6 +21,7 @@\n       <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n       {% endfor %}\n     </ul>\n+    {% endif %}\n   </aside>\n   <script>\n     class ChannelSidebar {\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex d9ad6d1..f1d4e14 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -1,7 +1,9 @@\n {% extends \"app.html\" %}\n \n {% block sidebar %}\n-Reboot\n+\n+{% include \"sidebar_channels.html\" %}\n+\n {% endblock %}\n \n {% block main %}"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Add channel list and user context to template rendering", "commit": "604e27ce10dee59b1b3f6ebd359ee356b085df2a", "diff": "commit 604e27ce10dee59b1b3f6ebd359ee356b085df2a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:42:38 2025 +0100\n\n    Changes.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a7bc195..81259bc 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -143,6 +143,31 @@ class Application(BaseApplication):\n \n     async def render_template(self, template, request, context=None):\n+        channels = []\n+        if not context:\n+            context = {}\n+        if request.session.get(\"uid\"): \n+            async for subscribed_channel in self.services.channel_member.find(user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+                item = {}\n+                other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n+                parent_object = await subscribed_channel.get_channel()\n+                last_message =await parent_object.get_last_message()\n+                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n+                if other_user:\n+                    item[\"name\"] = other_user[\"nick\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                else:\n+                    item[\"name\"] = subscribed_channel[\"label\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                channels.append(item)\n+            \n+            channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n+            if not 'channels' in context:\n+                context['channels'] = channels\n+            if not 'user' in context:\n+                context['user'] = await self.services.user.get(uid=request.session.get(\"uid\"))\n+\n         return await super().render_template(template, request, context)\n \n \ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex e54dfb7..8af82e7 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -17,9 +17,8 @@ class TerminalSocketView(BaseView):\n         terminal_folder = pathlib.Path(\"terminal\")\n         for path in terminal_folder.iterdir():\n             destination_path = root.joinpath(path.name)\n-            if not destination_path.exists():\n-                if not path.is_dir():\n-                    destination_path.write_bytes(path.read_bytes())\n+            if not path.is_dir():\n+                destination_path.write_bytes(path.read_bytes())\n         return root \n         \n     async def get(self):\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 2b5d791..d8b23c1 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,11 +94,11 @@ fi\n \n cp ~/r /usr/local/bin \n \n+chmod -x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y\n \n-echo \"r is installed.\"\n+echo \"R is installed. Type r to run it.\"\n \n@@ -106,3 +106,4 @@ echo \"r is installed.\"\n+export PS1=\"root@snek: \""}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c72b015073347e86f2edf1e67544b1ae31e929b0", "diff": "commit c72b015073347e86f2edf1e67544b1ae31e929b0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 20:55:57 2025 +0100\n\n    Update .bashrc\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex d8b23c1..0135ff9 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,7 +94,7 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod -x /usr/local/bin/r\n+chmod +x /usr/local/bin/r\n \n apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Disable r executable and add vim and htop to apt install", "commit": "78c631e6c79b4b69b0e202a301b710c4150e6dbe", "diff": "commit 78c631e6c79b4b69b0e202a301b710c4150e6dbe\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 21:01:15 2025 +0100\n\n    Updated .bashrc.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 0135ff9..d215a7b 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,9 +94,9 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod +x /usr/local/bin/r\n+chmod -x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y\n \n echo \"R is installed. Type r to run it.\""}
{"repo": ".", "date": "2025-03-22", "line": "fix: Make r executable", "commit": "c8461342fd8d260da69c84f27f9e9d13b3430942", "diff": "commit c8461342fd8d260da69c84f27f9e9d13b3430942\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 22 22:46:19 2025 +0100\n\n    Update.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex d215a7b..b52a4d0 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -94,7 +94,7 @@ fi\n \n cp ~/r /usr/local/bin \n \n-chmod -x /usr/local/bin/r\n+chmod +x /usr/local/bin/r\n \n apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Introduce ThreadPoolExecutor for asynchronous task handling", "commit": "0bc24e8d2ef4452efc7be5286288a2531908ea55", "diff": "commit 0bc24e8d2ef4452efc7be5286288a2531908ea55\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 02:16:24 2025 +0100\n\n    New executor.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 81259bc..eb26333 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,7 +38,7 @@ from snek.view.search_user import SearchUserView\n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n from snek.view.terminal import TerminalView, TerminalSocketView\n-\n+from  concurrent.futures import ThreadPoolExecutor\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -170,7 +170,10 @@ class Application(BaseApplication):\n \n         return await super().render_template(template, request, context)\n \n+executor = ThreadPoolExecutor(max_workers=100)\n \n+loop = asyncio.get_event_loop()\n+loop.set_default_executor(executor)\n "}
{"repo": ".", "date": "2025-03-23", "line": "feat: Improve terminal handling and UI responsiveness", "commit": "c2d9af807a95900c4fecd7a1929c8d92393a955d", "diff": "commit c2d9af807a95900c4fecd7a1929c8d92393a955d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 02:53:36 2025 +0100\n\n    Terminal changes.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 2d9341e..0b012ba 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -16,6 +16,7 @@ class TerminalSession:\n     def __init__(self,command):\n         self.master, self.slave = pty.openpty()\n         self.sockets =[]\n+        self.buffer = b''\n         self.process = subprocess.Popen(\n             command.split(\" \"),\n             stdin=self.slave,\n@@ -29,17 +30,30 @@ class TerminalSession:\n         loop = asyncio.get_event_loop()\n         self.sockets.append(ws)\n         if len(self.sockets) > 1:\n+            start = self.buffer.index(b'\\n')\n+            await ws.send_bytes(self.buffer[start:])\n             return \n         while True:\n             try:\n                 data = await loop.run_in_executor(None, os.read, self.master, 1024)\n                 if not data:\n                     break\n+                self.buffer += data\n+                if len(self.buffer) > 10000:\n+                    self.buffer = self.buffer[:-10000]\n                 try:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\n+                print(\"Terminating process\")\n+                self.process.terminate()\n+                print(\"Terminated process\")\n+                for ws in self.sockets:\n+                    try:\n+                        await ws.close()\n+                    except Exception:\n+                        pass\n                 break\n \n     async def write_input(self, data):\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex f1d4e14..335a85f 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -11,7 +11,8 @@\n     <style>\n     </style>\n \n   <div class=\"container\" id=\"terminal\"></div>"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Install git alongside dependencies", "commit": "c5c160baae67d7e5932963f8501ed7d56dc35c21", "diff": "commit c5c160baae67d7e5932963f8501ed7d56dc35c21\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 03:16:43 2025 +0100\n\n    Updated bsahrc.\n\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex b52a4d0..e4022a9 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -96,7 +96,7 @@ cp ~/r /usr/local/bin\n \n chmod +x /usr/local/bin/r\n \n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop -y\n+apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git -y\n \n echo \"R is installed. Type r to run it.\""}
{"repo": ".", "date": "2025-03-23", "line": "feat: Added drive functionality with views and models", "commit": "7b32a7eba4d5944142a3b40616d37b0862087371", "diff": "commit 7b32a7eba4d5944142a3b40616d37b0862087371\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 13:57:20 2025 +0100\n\n    Update drive.\n\ndiff --git a/Makefile b/Makefile\nindex 62d24ca..c9a7a28 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -15,7 +15,7 @@ run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\n install:\n-\tpython3 -m venv .venv \n+\tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n \n \ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex eb26333..daefbd0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,6 +38,7 @@ from snek.view.search_user import SearchUserView\n from snek.view.avatar import AvatarView\n from snek.system.profiler import profiler_handler\n from snek.view.terminal import TerminalView, TerminalSocketView\n+from snek.view.drive import DriveView\n from  concurrent.futures import ThreadPoolExecutor\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -67,6 +68,13 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n+       \n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_database)\n+\n+    async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n \n@@ -79,10 +87,8 @@ class Application(BaseApplication):\n                 self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n         except:\n             pass \n-        \n-        self.cache = Cache(self)\n-        self.services = get_services(app=self)\n-        self.mappers = get_mappers(app=self)\n+ \n+        await app.services.drive.prepare_all()\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n@@ -117,6 +123,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n         self.router.add_view(\"/terminal.html\", TerminalView)\n+        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n \n         self.add_subapp(\n             \"/docs\",\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex a310bbd..3936d97 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -4,4 +4,10 @@ from snek.system.model import BaseModel,ModelField\n class DriveModel(BaseModel):\n \n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    \n+    name = ModelField(name='name', required=False, type=str) \n+\n+    @property\n+    async def items(self):\n+        async for drive_item in self.app.services.drive_item.find(drive_uid=self['uid']):\n+            yield drive_item\n+\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 74b8deb..728cd89 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,5 +1,5 @@\n from snek.system.model import BaseModel,ModelField \n-\n+import mimetypes\n \n class DriveItemModel(BaseModel):\n     drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n@@ -7,3 +7,12 @@ class DriveItemModel(BaseModel):\n     path = ModelField(name=\"path\", required=True,kind=str)\n     file_type = ModelField(name=\"file_type\", required=True,kind=str) \n     file_size = ModelField(name=\"file_size\", required=True,kind=int)\n+    \n+    @property\n+    def extension(self):\n+        return self['name'].split('.')[-1]\n+    \n+    @property \n+    def mime_type(self):\n+        mimetype,_ = mimetypes.guess_type(self['name'])\n+        return mimetype \ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex 9d409a8..b90a959 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -5,17 +5,75 @@ class DriveService(BaseService):\n     \n     mapper_name = \"drive\"\n \n-    async def get_by_user(self, user_uid):\n-        drives = [] \n-        async for model in self.find(user_uid=user_uid):\n-            drives.append(model)\n-        return drives \n-\n-    async def get_or_create(self, user_uid):\n-        drives = await self.get_by_user(user_uid=user_uid)\n-        if len(drives) == 0:\n-            model = await self.new()\n-            model['user_uid'] = user_uid  \n-            await self.save(model)\n-            return model \n-        return drives[0]\n+    EXTENSIONS_PICTURES = [\"jpg\",\"jpeg\",\"png\",\"gif\",\"svg\",\"webp\",\"tiff\"]\n+    EXTENSIONS_VIDEOS = [\"mp4\",\"m4v\",\"mov\",\"wmv\",\"webm\",\"mkv\",\"mpg\",\"mpeg\",\"avi\",\"ogv\",\"ogg\",\"flv\",\"3gp\",\"3g2\"]\n+    EXTENSIONS_ARCHIVES = [\"zip\",\"rar\",\"7z\",\"tar\",\"tar.gz\",\"tar.xz\",\"tar.bz2\",\"tar.lzma\",\"tar.lz\"]\n+    EXTENSIONS_AUDIO = [\"mp3\",\"wav\",\"ogg\",\"flac\",\"m4a\",\"wma\",\"aac\",\"opus\",\"aiff\",\"au\",\"mid\",\"midi\"]\n+    EXTENSIONS_DOCS = [\"pdf\",\"doc\",\"docx\",\"xls\",\"xlsx\",\"ppt\",\"pptx\",\"txt\",\"md\",\"json\",\"csv\",\"xml\",\"html\",\"css\",\"js\",\"py\",\"sql\",\"rs\",\"toml\",\"yml\",\"yaml\",\"ini\",\"conf\",\"config\",\"log\",\"csv\",\"tsv\",\"java\",\"cs\",\"csproj\",\"scss\",\"less\",\"sass\",\"json\",\"lock\",\"lock.json\",\"jsonl\"]\n+\n+    async def get_drive_name_by_extension(self, extension):\n+        if extension.startswith(\".\"):\n+            extension = extension[1:]\n+        if extension in self.EXTENSIONS_PICTURES:\n+            return \"Pictures\"\n+        if extension in self.EXTENSIONS_VIDEOS:\n+            return \"Videos\"\n+        if extension in self.EXTENSIONS_ARCHIVES:\n+            return \"Archives\"\n+        if extension in self.EXTENSIONS_AUDIO:\n+            return \"Audio\"\n+        if extension in self.EXTENSIONS_DOCS:\n+            return \"Documents\"\n+        return \"My Drive\"\n+\n+    async def get_drive_by_extension(self,user_uid, extension):\n+        name = await self.get_drive_name_by_extension(extension)\n+        return await self.get_or_create(user_uid=user_uid,name=name)\n+\n+    async def get_by_user(self, user_uid,name=None):\n+        kwargs = dict(\n+            user_uid = user_uid\n+        )\n+        async for model in self.find(**kwargs):\n+            if not name:\n+                yield model \n+            elif model['name'] == name:\n+                yield model\n+            elif not model['name'] and name == 'My Drive':\n+                model['name'] = 'My Drive'\n+                await self.save(model)\n+                yield model \n+\n+    async def get_or_create(self, user_uid,name=None,extensions=None):\n+        kwargs = dict(user_uid=user_uid)\n+        if name:\n+            kwargs['name'] = name\n+        async for model in self.get_by_user(**kwargs):\n+            return model  \n+\n+        model = await self.new()\n+        model['user_uid'] = user_uid\n+        model['name'] = name \n+        await self.save(model)\n+        return model \n+\n+    async def prepare_default_drives(self):\n+        async for drive_item in self.services.drive_item.find():\n+            extension = drive_item.extension\n+            drive = await self.get_drive_by_extension(drive_item['user_uid'],extension)\n+            if not drive_item['drive_uid'] == drive['uid']:\n+                drive_item['drive_uid'] = drive['uid']\n+                await self.services.drive_item.save(drive_item)\n+            \n+    async def prepare_default_drives_for_user(self, user_uid):\n+        await self.get_or_create(user_uid=user_uid,name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid,name=\"Documents\")\n+\n+    async def prepare_all(self):\n+        await self.prepare_default_drives()\n+        async for user in self.services.user.find():\n+            await self.prepare_default_drives_for_user(user['uid']) \ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 058f55e..05a7da8 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -10,6 +10,7 @@ class DriveItemService(BaseService):\n         model['drive_uid'] = drive_uid \n         model['name'] = name \n         model['path'] = str(path) \n+        model['extension'] = str(name).split(\".\")[-1]\n         model['file_type'] = type_ \n         model['file_size'] = size\n         if await self.save(model):\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 0b012ba..3495bef 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -13,20 +13,66 @@ commands = {\n }\n \n class TerminalSession:\n-    def __init__(self,command):\n-        self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.buffer = b''\n-        self.process = subprocess.Popen(\n-            command.split(\" \"),\n+    \n+    async def ensure_process(self):\n+        if self.process:\n+            return\n+        self.process = await asyncio.create_subprocess_exec(\n+            *self.command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n-            stderr=self.slave,\n-            bufsize=0,\n+            stderr=self.slave,bufsize=0,\n             universal_newlines=True\n         )\n \n+\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.buffer = b''\n+        self.process = None \n+\n+\n+\n     async def read_output(self, ws):\n+        await self.ensure_process()\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1:\n+            start = self.buffer.index(b'\\n')\n+            await ws.send_bytes(self.buffer[start:])\n+            return \n+        while True:\n+            try:\n+                async for data in self.process.stdout:\n+                    if not data:\n+                        break\n+                    self.buffer += data\n+                    if len(self.buffer) > 10000:\n+                        self.buffer = self.buffer[:-10000]\n+                    try:\n+                    except:\n+                        self.sockets.remove(ws)\n+            except:\n+                print(\"Terminating process\")\n+                self.process.terminate()\n+                print(\"Terminated process\")\n+                for ws in self.sockets:\n+                    try:\n+                        await ws.close()\n+                    except Exception:\n+                        pass\n+                break\n+\n+    async def read_outputa(self, ws):\n         loop = asyncio.get_event_loop()\n         self.sockets.append(ws)\n         if len(self.sockets) > 1:\n@@ -57,6 +103,7 @@ class TerminalSession:\n                 break\n \n     async def write_input(self, data):\n+        await self.ensure_process()\n         os.write(self.master, data.encode())\n \n \ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nnew file mode 100644\nindex 0000000..3e10c90\n--- /dev/null\n+++ b/src/snek/view/drive.py\n@@ -0,0 +1,30 @@\n+from snek.system.view import BaseView\n+from aiohttp import web\n+\n+class DriveView(BaseView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        drive_uid = self.request.match_info.get(\"drive\")\n+\n+        if drive_uid:\n+            drive = await self.services.drive.get(uid=drive_uid)\n+            drive_items = []\n+            async for item in drive.items:\n+                drive_items.append(item.record)\n+            return web.json_response(drive_items)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        \n+        drives = []\n+        async for drive in self.services.drive.get_by_user(user['uid']):\n+            record = drive.record\n+            record['items'] = []\n+            async for item in drive.items:\n+                record['items'].append(item.record)\n+            drives.append(record)\n+        \n+        return web.json_response(drives)"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Add URL to drive items", "commit": "dec2281ac88d151afda016fc01e833dc2f0aa89e", "diff": "commit dec2281ac88d151afda016fc01e833dc2f0aa89e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 14:08:10 2025 +0100\n\n    Update drive.\n\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 3e10c90..1f159fb 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -13,7 +13,9 @@ class DriveView(BaseView):\n             drive = await self.services.drive.get(uid=drive_uid)\n             drive_items = []\n             async for item in drive.items:\n-                drive_items.append(item.record)\n+                record = item.record\n+                record['url'] = '/drive.bin/' + record['uid'] + '.' + item.extension\n+                drive_items.append(record)\n             return web.json_response(drive_items)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n@@ -24,6 +26,8 @@ class DriveView(BaseView):\n             record = drive.record\n             record['items'] = []\n             async for item in drive.items:\n+                drive_item_record = item.record\n+                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + drive_item.extension\n                 record['items'].append(item.record)\n             drives.append(record)"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Correctly fetch file extension from item object", "commit": "1de2c55966c0ddf0fb663b935427d2c005f0fde9", "diff": "commit 1de2c55966c0ddf0fb663b935427d2c005f0fde9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 14:38:53 2025 +0100\n\n    Update drive.\n\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 1f159fb..1026cf7 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -27,7 +27,7 @@ class DriveView(BaseView):\n             record['items'] = []\n             async for item in drive.items:\n                 drive_item_record = item.record\n-                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + drive_item.extension\n+                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + item.extension\n                 record['items'].append(item.record)\n             drives.append(record)"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Improved terminal session handling and websocket integration", "commit": "529606955a545bc25cf5899f2c79d3660bcefd54", "diff": "commit 529606955a545bc25cf5899f2c79d3660bcefd54\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 18:46:10 2025 +0100\n\n    Repaired websockets.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex daefbd0..f200908 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -180,8 +180,8 @@ class Application(BaseApplication):\n \n executor = ThreadPoolExecutor(max_workers=100)\n \n-loop = asyncio.get_event_loop()\n-loop.set_default_executor(executor)\n \n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 3495bef..6a91040 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -13,80 +13,42 @@ commands = {\n }\n \n class TerminalSession:\n-    \n-    async def ensure_process(self):\n-        if self.process:\n-            return\n-        self.process = await asyncio.create_subprocess_exec(\n-            *self.command.split(\" \"),\n+    def __init__(self,command):\n+        self.master, self.slave = pty.openpty()\n+        self.sockets =[]\n+        self.history = b''\n+        self.history_size = 1024*20\n+        self.process = subprocess.Popen(\n+            command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n-            stderr=self.slave,bufsize=0,\n+            stderr=self.slave,\n+            bufsize=0,\n             universal_newlines=True\n         )\n \n-\n-    def __init__(self,command):\n-        self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.buffer = b''\n-        self.process = None \n-\n-\n+    async def add_websocket(self, ws):\n+        asyncio.create_task(self.read_output(ws))\n \n     async def read_output(self, ws):\n-        await self.ensure_process()\n         self.sockets.append(ws)\n-        if len(self.sockets) > 1:\n-            start = self.buffer.index(b'\\n')\n-            await ws.send_bytes(self.buffer[start:])\n-            return \n-        while True:\n+        if len(self.sockets) > 1 and self.buffer:\n+            start = 0\n             try:\n-                async for data in self.process.stdout:\n-                    if not data:\n-                        break\n-                    self.buffer += data\n-                    if len(self.buffer) > 10000:\n-                        self.buffer = self.buffer[:-10000]\n-                    try:\n-                    except:\n-                        self.sockets.remove(ws)\n-            except:\n-                print(\"Terminating process\")\n-                self.process.terminate()\n-                print(\"Terminated process\")\n-                for ws in self.sockets:\n-                    try:\n-                        await ws.close()\n-                    except Exception:\n-                        pass\n-                break\n-\n-    async def read_outputa(self, ws):\n-        loop = asyncio.get_event_loop()\n-        self.sockets.append(ws)\n-        if len(self.sockets) > 1:\n-            start = self.buffer.index(b'\\n')\n-            await ws.send_bytes(self.buffer[start:])\n+                start = self.history.index(b'\\n')\n+            except ValueError:\n+                pass \n+            await ws.send_bytes(self.history[start:])\n             return \n+        loop = asyncio.get_event_loop()\n         while True:\n             try:\n                 data = await loop.run_in_executor(None, os.read, self.master, 1024)\n                 if not data:\n                     break\n-                self.buffer += data\n-                if len(self.buffer) > 10000:\n-                    self.buffer = self.buffer[:-10000]\n+                self.history += data\n+                if len(self.history) > self.history_size:\n+                    self.history = self.history[:0-self.history_size]\n                 try:\n                 except:\n@@ -103,7 +65,10 @@ class TerminalSession:\n                 break\n \n     async def write_input(self, data):\n-        await self.ensure_process()\n-        os.write(self.master, data.encode())\n-\n-\n+        try:\n+            data = data.encode()\n+        except AttributeError:\n+            pass\n+        await asyncio.get_event_loop().run_in_executor(\n+            None, os.write, self.master, data\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 8af82e7..26de464 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -37,7 +37,8 @@ class TerminalSocketView(BaseView):\n         if not session:\n             self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n         session = self.user_sessions[user[\"uid\"]]   \n-        asyncio.create_task(session.read_output(ws))  \n+        await session.add_websocket(ws)\n \n         async for msg in ws:\n             if msg.type == aiohttp.WSMsgType.BINARY:"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Increased websocket thread pool size", "commit": "af4a70e8949bfde704a0499177050fdeab5300d9", "diff": "commit af4a70e8949bfde704a0499177050fdeab5300d9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 23 18:52:12 2025 +0100\n\n    Repaired websockets.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f200908..ecc1999 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -178,10 +178,10 @@ class Application(BaseApplication):\n \n         return await super().render_template(template, request, context)\n \n-executor = ThreadPoolExecutor(max_workers=100)\n+executor = ThreadPoolExecutor(max_workers=200)\n \n+loop = asyncio.get_event_loop()\n+loop.set_default_executor(executor)\n "}
{"repo": ".", "date": "2025-03-27", "line": "feat: Display channel color and new message count in sidebar", "commit": "5390b8bdc3dc04645258ed758f3894de00008e80", "diff": "commit 5390b8bdc3dc04645258ed758f3894de00008e80\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:10:02 2025 +0100\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ecc1999..1d225cc 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -160,6 +160,11 @@ class Application(BaseApplication):\n                 other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n                 parent_object = await subscribed_channel.get_channel()\n                 last_message =await parent_object.get_last_message()\n+                color = None \n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                item['color'] = color\n                 item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n                 item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n                 if other_user:\n@@ -168,6 +173,9 @@ class Application(BaseApplication):\n                 else:\n                     item[\"name\"] = subscribed_channel[\"label\"]\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                item['new_count'] = subscribed_channel['new_count'] \n+                \n+                print(item)\n                 channels.append(item)\n             \n             channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 0070948..649299e 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -13,9 +13,10 @@ class ChannelModel(BaseModel):\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n     async def get_last_message(self)->ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n-            return model \n+        async for model in self.app.services.channel_message.query(\"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n+            \n+            return await self.app.services.channel_message.get(uid=model['uid'])\n         return None\n \n     async def get_members(self):\n-        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n\\ No newline at end of file\n+        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex f8a87c8..a5d4ebd 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -51,6 +51,7 @@ class NotificationService(BaseService):\n             model[\"message\"] = (\n                 f\"New message from {user['nick']} in {channel_member['label']}.\"\n             )\n-            if await self.save(model):\n-                return model\n-            raise Exception(f\"Failed to create notification: {model.errors}.\")\n+            try:\n+                await self.save(model)\n+            except Exception as ex:\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 2d034b7..66371f1 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -12,13 +12,13 @@\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\n       {% for channel in channels if not channel['is_private'] %}\n-      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" {% if channel['color'] %}style=\"color: {{channel['color']}}\"{% endif %}  href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\">{% if channel['new_count'] %}({{ channel['new_count'] }}){% endif %}</span></a></li>\n       {% endfor %}\n     </ul>\n     <h2 class=\"no-select\">Private</h2>\n     <ul>\n       {% for channel in channels if channel['is_private'] %}\n-      <li id=\"channel-list-item-{{channel['uid']}}\"><a class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\"></span></a></li>\n+      <li id=\"channel-list-item-{{channel['uid']}}\"><a  {% if channel['color'] %}style=\"color: {{channel['color']}}\"{% endif %}  class=\"no-select\" href=\"/channel/{{channel['uid']}}.html\">{{channel['name']}} <span class=\"message-count\">{% if channel['new_count'] %}({{ channel['new_count'] }}){% endif %}</span></a></li>\n       {% endfor %}\n     </ul>\n     {% endif %}\n@@ -28,12 +28,21 @@\n         constructor(el){\n           this.el = el \n         }\n+        async init(){\n+            \n+            const channels = await window.app.rpc.getChannels()\n+            channels.forEach(channel => {\n+                if(channel.color){\n+                this.setMessageCount(channel.uid, channel.new_count)\n+                this.setColor(channel.uid, channel.color)\n+                }\n+            })\n+        }\n         get channelNodes() {\n           return this.el.querySelectorAll(\"li\")\n         }\n         channelListItemByUid(channelUid){\n           const id = \"channel-list-item-\" + channelUid;\n-          console.error(id)\n           return document.getElementById(id)\n         }\n         incrementMessageCount(channelUid){\n@@ -52,11 +61,22 @@\n           }\n         }\n         setMessageCount(channelUid, count){\n-          const li = this.channelListItemByUid(channelUid);\n+            \n+            const li = this.channelListItemByUid(channelUid);\n           if(li){\n             li.dataset.messageCount = new String(count)\n             li.dataset['messageCount'] = count\n-            li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+            if(!count){\n+                li.querySelector(\".message-count\").textContent = ''\n+            }else{\n+              li.querySelector(\".message-count\").textContent = '(' + count + ')'\n+            }\n+          }\n+        }\n+        setColor(channelUid, color){\n+          const li = this.channelListItemByUid(channelUid);\n+            if(li){\n+                li.querySelector(\"a\").style.color = color\n           }\n         }\n         notify(message){\n@@ -73,4 +93,7 @@\n     }\n     const channelSidebar = new ChannelSidebar(document.getElementById(\"channelSidebar\"))\n \n+    document.addEventListener(\"DOMContentLoaded\", () => {\n+    channelSidebar.init()\n+    })\n   </script>\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 55accc7..b5c3504 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -90,13 +90,20 @@ class RPCView(BaseView):\n             channels = []\n             async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n                 channel = await self.services.channel.get(uid=subscription['channel_uid'])\n+                last_message = await channel.get_last_message()\n+                color = None \n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user['color']\n                 channels.append({\n                     \"name\": subscription[\"label\"],\n                     \"uid\": subscription[\"channel_uid\"],\n                     \"tag\": channel[\"tag\"],\n                     \"new_count\": subscription[\"new_count\"],\n                     \"is_moderator\": subscription[\"is_moderator\"],\n-                    \"is_read_only\": subscription[\"is_read_only\"]\n+                    \"is_read_only\": subscription[\"is_read_only\"],\n+                    'new_count': subscription['new_count'],\n+                    'color': color \n                 })\n             return channels\n \n@@ -156,9 +163,11 @@ class RPCView(BaseView):\n                 if result != \"noresponse\":\n                     await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n             except Exception as ex:\n+                print(str(ex), flush=True)\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n         async def _send_json(self, obj):\n+            print(obj, flush=True)\n             await self.ws.send_str(json.dumps(obj, default=str))\n     \n \ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex cdde6e3..b9271c3 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -46,6 +46,9 @@ class WebView(BaseView):\n         channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n         if not channel_member:\n             return web.HTTPNotFound()\n+        \n+        channel_member['new_count'] = 0\n+        await self.app.services.channel_member.save(channel_member)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n@@ -54,23 +57,5 @@ class WebView(BaseView):\n         for message in messages:\n             await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n \n-        channels = []\n-        async for subscribed_channel in self.app.services.channel_member.find(user_uid=self.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-            item = {}\n-            other_user = await self.app.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], self.session.get(\"uid\"))\n-            parent_object = await subscribed_channel.get_channel()\n-            last_message =await parent_object.get_last_message()\n-            item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n-            item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n-            if other_user:\n-                item[\"name\"] = other_user[\"nick\"]\n-                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-            else:\n-                item[\"name\"] = subscribed_channel[\"label\"]\n-                item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-            channels.append(item)\n-        \n-        channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n-\n         name = await channel_member.get_name()\n-        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages , \"channels\": channels})\n+        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages})"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Prevent errors when user data is missing during notification updates", "commit": "877ef7970d5b7bb5f8fd0af35adad5d8d071b14d", "diff": "commit 877ef7970d5b7bb5f8fd0af35adad5d8d071b14d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:15:39 2025 +0100\n\n    Bugfix.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a5d4ebd..e6915bf 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -41,6 +41,8 @@ class NotificationService(BaseService):\n             channel_member['new_count'] += 1\n             \n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if not usr:\n+                continue\n             print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Remove debugging prints", "commit": "87c189b3fe897cc15ee6ac311e987bf9fe7811b9", "diff": "commit 87c189b3fe897cc15ee6ac311e987bf9fe7811b9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:28:54 2025 +0100\n\n    Removed useless prints.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 42415d1..5c6c7ee 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -5,6 +5,13 @@ class ChannelMemberService(BaseService):\n \n     mapper_name = \"channel_member\"\n \n+    async def mark_as_read(self, channel_uid, user_uid):\n+        channel_member =  await self.get(\n+            channel_uid=channel_uid,\n+            user_uid=user_uid\n+        )\n+        channel_member[\"new_count\"] = 0\n+        return await self.save(channel_member)\n \n     async def create(\n         self,\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex e6915bf..0b54a23 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -43,7 +43,6 @@ class NotificationService(BaseService):\n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if not usr:\n                 continue\n-            print(\"INSERTED FOR \",usr[\"username\"],flush=True)\n             await self.services.channel_member.save(channel_member)\n \n             model = await self.new()\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex e7415e1..3b6c7c6 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -49,7 +49,6 @@ class BaseMapper:\n         if not model.record.get(\"uid\"):\n             raise Exception(f\"Attempt to save without uid: {model.record}.\")\n         model.updated_at.update()\n-        print(model.record,flush=True)\n         return self.table.upsert(model.record, [\"uid\"])\n \n     async def find(self, **kwargs) -> typing.AsyncGenerator:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 5371d33..3d52892 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -70,4 +70,3 @@ class BaseFormView(BaseView):\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\n-        print(\"Submit sucess\")\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8a68f64..28ad4d2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -184,6 +184,7 @@\n         setTimeout(() => {\n             updateLayout(doScrollDownBecauseLastMessageIsVisible)\n         }, 1000);\n+        app.rpc.markAsRead(channelUid);\n     });\n \n     initInputField(getInputField());\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex b5c3504..27a8dcf 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -39,9 +39,9 @@ class RPCView(BaseView):\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n-        async def mark_as_read(self, message_uid):\n+        async def mark_as_read(self, channel_uid):\n             self._require_login()\n-            await self.services.notification.mark_as_read(self.user_uid, message_uid)\n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid) \n             return True\n \n         async def login(self, username, password):\n@@ -167,7 +167,6 @@ class RPCView(BaseView):\n                 await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n \n         async def _send_json(self, obj):\n-            print(obj, flush=True)\n             await self.ws.send_str(json.dumps(obj, default=str))\n     \n \ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 26de464..fac680c 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -31,7 +31,6 @@ class TerminalSocketView(BaseView):\n         root = await self.prepare_drive()\n         \n         command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n-        print(command)\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Removed unnecessary print statements", "commit": "71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71", "diff": "commit 71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 17:30:06 2025 +0100\n\n    Removed useless prints.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 3d52892..2765642 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -70,3 +70,4 @@ class BaseFormView(BaseView):\n         return await self.json_response(result)\n \n     async def submit(self, model=None):\n+        pass"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction for notification creation", "commit": "bd5bb5ae65d6cb870d090382b106b705833d4cf1", "diff": "commit bd5bb5ae65d6cb870d090382b106b705833d4cf1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:10:05 2025 +0100\n\n    Transaction.\n\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 0b54a23..1392044 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -30,6 +30,7 @@ class NotificationService(BaseService):\n             uid=channel_message_uid\n         )\n         user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        self.app.db.begin()\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_message[\"channel_uid\"],\n             is_banned=False,\n@@ -56,3 +57,5 @@ class NotificationService(BaseService):\n                 await self.save(model)\n             except Exception as ex:\n                 raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+        self.app.db.commit()"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Persist RPC transaction changes to database", "commit": "13ce09a5c50ddba8956dbeb838f9dd1bcafe184a", "diff": "commit 13ce09a5c50ddba8956dbeb838f9dd1bcafe184a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:20:48 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 27a8dcf..5720f04 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -203,7 +203,9 @@ class RPCView(BaseView):\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     async with Profiler():\n+                        self.app.db.begin()\n                         await rpc(msg.json())\n+                        self.app.db.commit()\n                 except Exception as ex:\n                     print(ex, flush=True)\n                     await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task runner and task queue for asynchronous operations", "commit": "145373399dada2f4cee54af0c99e62d4a27a0f99", "diff": "commit 145373399dada2f4cee54af0c99e62d4a27a0f99\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:41:04 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 1d225cc..3094dd5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -61,6 +61,7 @@ class Application(BaseApplication):\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n+        self.tasks = []\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n@@ -68,12 +69,28 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n         \n         self.setup_router()\n-       \n+     \n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n         self.on_startup.append(self.prepare_database)\n \n+    async def create_task(self, task):\n+        self.tasks.append(task)\n+\n+    async def task_runner(self):\n+        while True:\n+            await asyncio.sleep(0.1)\n+            task = None\n+            try:\n+                task = self.tasks.pop(0)\n+            except IndexError:\n+                continue \n+            try:\n+                await task\n+            except:\n+                print(ex)\n+\n     async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n@@ -89,6 +106,7 @@ class Application(BaseApplication):\n             pass \n  \n         await app.services.drive.prepare_all()\n+        self.loop.create_task(self.task_runner())\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 17c677b..ecaedb7 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -21,7 +21,10 @@ class ChatService(BaseService):\n         \n         user = await self.services.user.get(uid=user_uid)\n         await self.services.notification.create_channel_message(channel_message_uid)\n-        sent_to_count = await self.services.socket.broadcast(channel_uid, dict(\n+        channel['last_message_on'] = now()\n+        await self.services.channel.save(channel)\n+        \n+        await self.app.create_task(self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -32,7 +35,5 @@ class ChatService(BaseService):\n             username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n-        ))\n-        channel['last_message_on'] = now()\n-        await self.services.channel.save(channel)\n-        return sent_to_count\n\\ No newline at end of file\n+        )))\n+        return True"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Improve task management with asyncio.Queue and error handling", "commit": "e6f702a6b405f1dae0ce719177c6f7bf5b636ad8", "diff": "commit e6f702a6b405f1dae0ce719177c6f7bf5b636ad8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 20:56:35 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3094dd5..7c0f0b0 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -61,7 +61,7 @@ class Application(BaseApplication):\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n-        self.tasks = []\n+        self.tasks = asyncio.Queue()\n         self._middlewares.append(session_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n@@ -76,19 +76,14 @@ class Application(BaseApplication):\n         self.on_startup.append(self.prepare_database)\n \n     async def create_task(self, task):\n-        self.tasks.append(task)\n+        await self.tasks.put(task)\n \n     async def task_runner(self):\n         while True:\n-            await asyncio.sleep(0.1)\n-            task = None\n-            try:\n-                task = self.tasks.pop(0)\n-            except IndexError:\n-                continue \n+            task = await self.tasks.get() \n             try:\n                 await task\n-            except:\n+            except Exception as ex:\n                 print(ex)\n \n     async def prepare_database(self,app):\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 0b6071d..d941321 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -62,12 +62,12 @@ class SocketService(BaseService):\n     async def broadcast(self, channel_uid, message):\n         count = 0\n         async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n-            count += await self.send_to_user(channel_member[\"user_uid\"],message)\n-        return count\n+            await self.send_to_user(channel_member[\"user_uid\"],message)\n+        return True \n     \n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n             self.sockets.remove(s)\n             \n-    \n\\ No newline at end of file\n+"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Remove unnecessary database transaction block in RPCView", "commit": "9d5815ed1028354ac61f2d506530147a02c476e6", "diff": "commit 9d5815ed1028354ac61f2d506530147a02c476e6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:00:34 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 5720f04..27a8dcf 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -203,9 +203,7 @@ class RPCView(BaseView):\n             if msg.type == web.WSMsgType.TEXT:\n                 try:\n                     async with Profiler():\n-                        self.app.db.begin()\n                         await rpc(msg.json())\n-                        self.app.db.commit()\n                 except Exception as ex:\n                     print(ex, flush=True)\n                     await self.services.socket.delete(ws)"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction management for task execution", "commit": "8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2", "diff": "commit 8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:01:17 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7c0f0b0..67beb5d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -81,10 +81,12 @@ class Application(BaseApplication):\n     async def task_runner(self):\n         while True:\n             task = await self.tasks.get() \n+            self.db.begin()\n             try:\n                 await task\n             except Exception as ex:\n                 print(ex)\n+            self.db.commit()\n \n     async def prepare_database(self,app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task execution time measurement", "commit": "6ad3844f037735e268b42247fcc6e8605cc13f07", "diff": "commit 6ad3844f037735e268b42247fcc6e8605cc13f07\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:07:04 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 67beb5d..dd3e0f3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,6 +2,7 @@ import pathlib\n import asyncio\n \n import logging\n+import time\n \n from snek.view.threads import ThreadsView \n \n@@ -83,7 +84,11 @@ class Application(BaseApplication):\n             task = await self.tasks.get() \n             self.db.begin()\n             try:\n+                task_start = time.time()\n                 await task\n+                task_end = time.time()\n+                print(f\"Task {task} took {task_end - task_start} seconds\")\n+                self.tasks.task_done()\n             except Exception as ex:\n                 print(ex)\n             self.db.commit()"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Asynchronously create channel messages and improve socket broadcast", "commit": "73e8779bdc42ec5f5618fad3d563544d1fad2b69", "diff": "commit 73e8779bdc42ec5f5618fad3d563544d1fad2b69\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:11:02 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex ecaedb7..a008c70 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -20,11 +20,11 @@ class ChatService(BaseService):\n         \n         \n         user = await self.services.user.get(uid=user_uid)\n-        await self.services.notification.create_channel_message(channel_message_uid)\n+        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n         channel['last_message_on'] = now()\n         await self.services.channel.save(channel)\n         \n-        await self.app.create_task(self.services.socket.broadcast(channel_uid, dict(\n+        self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -35,5 +35,5 @@ class ChatService(BaseService):\n             username=user['username'],\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n-        )))\n+        ))\n         return True"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Ensure notification creation after socket broadcast", "commit": "6f043d21390d13d37772c8ae145d7a66b3919529", "diff": "commit 6f043d21390d13d37772c8ae145d7a66b3919529\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:12:19 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex a008c70..aef0cdd 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -20,11 +20,10 @@ class ChatService(BaseService):\n         \n         \n         user = await self.services.user.get(uid=user_uid)\n-        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n         channel['last_message_on'] = now()\n         await self.services.channel.save(channel)\n-        \n-        self.services.socket.broadcast(channel_uid, dict(\n+         \n+        await self.services.socket.broadcast(channel_uid, dict(\n             message=channel_message[\"message\"],\n             html=channel_message[\"html\"],\n             user_uid=user_uid,\n@@ -36,4 +35,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n+        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n+       \n         return True"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Await task creation in chat service", "commit": "9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec", "diff": "commit 9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Mar 27 21:14:01 2025 +0100\n\n    Transactions.\n\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex aef0cdd..8b1f8ad 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -35,6 +35,6 @@ class ChatService(BaseService):\n             uid=channel_message['uid'],\n             user_nick=user['nick']\n         ))\n-        async self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n+        await self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n        \n         return True"}
{"repo": ".", "date": "2025-03-28", "line": "feat: Initialized Ubuntu Dockerfile and Makefile build process.", "commit": "5fbcadad8bad7b8dd7ac3279938ff50db6b5d380", "diff": "commit 5fbcadad8bad7b8dd7ac3279938ff50db6b5d380\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri Mar 28 02:41:57 2025 +0100\n\n    Terminal Update.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nnew file mode 100644\nindex 0000000..45c6038\n--- /dev/null\n+++ b/DockerfileUbuntu\n@@ -0,0 +1,11 @@\n+FROM ubuntu:latest\n+\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget -y\n+\n+\n+RUN chmod +x r\n+\n+RUN cp r /usr/local/bin\n+\n+CMD [\"r\"]\ndiff --git a/Makefile b/Makefile\nindex c9a7a28..878e699 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -11,12 +11,15 @@ python:\n dump:\n \t@$(PYTHON) -m snek.dump\n \n+build:\n+\n+\n run:\n \t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n \t\n install:\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n-\n+\tdocker build -f DockerfileUbuntu -t snek_ubuntu .\n \n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 6a91040..80dda17 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -32,7 +32,7 @@ class TerminalSession:\n \n     async def read_output(self, ws):\n         self.sockets.append(ws)\n-        if len(self.sockets) > 1 and self.buffer:\n+        if len(self.sockets) > 1 and self.history:\n             start = 0\n             try:\n                 start = self.history.index(b'\\n')\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex fac680c..6596f2c 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -30,7 +30,10 @@ class TerminalSocketView(BaseView):\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = await self.prepare_drive()\n         \n-        command = f\"docker run -v ./{root}/:/root --rm -it --memory 512M --cpus=0.5 -w /root ubuntu:latest /bin/bash\"\n+\n+        \n+\n+        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex e4022a9..ee7d93f 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -92,11 +92,6 @@ if [ -f ~/.bash_aliases ]; then\n fi\n \n \n-cp ~/r /usr/local/bin \n-\n-chmod +x /usr/local/bin/r\n-\n-apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git -y\n \n echo \"R is installed. Type r to run it.\"\n \ndiff --git a/terminal/r b/terminal/r\ndeleted file mode 100755\nindex 2cc65df..0000000\nBinary files a/terminal/r and /dev/null differ"}
{"repo": ".", "date": "2025-03-29", "line": "fix: Adjusted avatar size in message template", "commit": "fe1b3d6d191176111538f1ba06018e14c44ca8f9", "diff": "commit fe1b3d6d191176111538f1ba06018e14c44ca8f9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 01:21:33 2025 +0100\n\n    Fixed avatar issue\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 0b475d1..31a6e74 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -254,6 +254,7 @@ input[type=\"text\"], .chat-input textarea {\n   }\n \n   .avatar {\n+    \n     opacity: 0;\n   }\n \ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex e8afc31..9773ae1 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>"}
{"repo": ".", "date": "2025-03-29", "line": "```\nfeat: Added webdav support\n\nThis commit introduces webdav functionality to the application.\n- Added webdav dependency\n- Implemented WebdavApplication class\n- Added webdav route to the application\n- Added drive item mapper\n- Added drive mapper\n```", "commit": "29139d5d0c18ad2b0ebd32db9a0b629c45c0a651", "diff": "commit 29139d5d0c18ad2b0ebd32db9a0b629c45c0a651\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:13:23 2025 +0100\n\n    Added webdav.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex e1075c9..6fbf200 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -15,6 +15,8 @@ keywords = [\"chat\", \"snek\", \"molodetz\"]\n requires-python = \">=3.12\"\n dependencies = [\n     \"mkdocs>=1.4.0\",\n+    \"lxml\",\n+\n     \"shed\",\n     \"beautifulsoup4\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex dd3e0f3..984fcf3 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,13 +1,14 @@\n-import pathlib\n import asyncio\n-\n import logging\n+import pathlib\n import time\n \n-from snek.view.threads import ThreadsView \n+from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n \n+from concurrent.futures import ThreadPoolExecutor\n+\n from aiohttp import web\n from aiohttp_session import (\n     get_session as session_get,\n@@ -24,23 +25,24 @@ from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n from snek.system.middleware import cors_middleware\n-from snek.system.template import LinkifyExtension, PythonExtension,EmojiExtension\n+from snek.system.profiler import profiler_handler\n+from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.view.avatar import AvatarView\n from snek.view.docs import DocsHTMLView, DocsMDView\n+from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n+from snek.view.search_user import SearchUserView\n from snek.view.status import StatusView\n-from snek.view.web import WebView\n+from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n-from snek.view.search_user import SearchUserView \n-from snek.view.avatar import AvatarView\n-from snek.system.profiler import profiler_handler\n-from snek.view.terminal import TerminalView, TerminalSocketView\n-from snek.view.drive import DriveView\n-from  concurrent.futures import ThreadPoolExecutor\n+from snek.view.web import WebView\n+from snek.webdav import WebdavApplication\n+\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -50,6 +52,15 @@ async def session_middleware(request, handler):\n     response = await handler(request)\n     return response\n \n+\n+@web.middleware\n+async def trailing_slash_middleware(request, handler):\n+    if request.path and not request.path.endswith(\"/\"):\n+        raise web.HTTPFound(request.path + \"/\")\n+    return await handler(request)\n+\n+\n class Application(BaseApplication):\n \n     def __init__(self, *args, **kwargs):\n@@ -68,9 +79,9 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n-        \n+\n         self.setup_router()\n-     \n+\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n@@ -81,7 +92,7 @@ class Application(BaseApplication):\n \n     async def task_runner(self):\n         while True:\n-            task = await self.tasks.get() \n+            task = await self.tasks.get()\n             self.db.begin()\n             try:\n                 task_start = time.time()\n@@ -93,20 +104,20 @@ class Application(BaseApplication):\n                 print(ex)\n             self.db.commit()\n \n-    async def prepare_database(self,app):\n+    async def prepare_database(self, app):\n         self.db.query(\"PRAGMA journal_mode=WAL\")\n         self.db.query(\"PRAGMA syncnorm=off\")\n \n         try:\n             if not self.db[\"user\"].has_index(\"username\"):\n                 self.db[\"user\"].create_index(\"username\", unique=True)\n-            if not self.db[\"channel_member\"].has_index([\"channel_uid\",\"user_uid\"]):\n-                self.db[\"channel_member\"].create_index([\"channel_uid\",\"user_uid\"])\n-            if not self.db[\"channel_message\"].has_index([\"channel_uid\",\"user_uid\"]):\n-                self.db[\"channel_message\"].create_index([\"channel_uid\",\"user_uid\"])\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n         except:\n-            pass \n- \n+            pass\n+\n         await app.services.drive.prepare_all()\n         self.loop.create_task(self.task_runner())\n \n@@ -145,6 +156,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/terminal.html\", TerminalView)\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.webdav = WebdavApplication(self)\n+        self.add_subapp(\"/webdav\", self.webdav)\n \n         self.add_subapp(\n             \"/docs\",\n@@ -174,17 +187,21 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-        if request.session.get(\"uid\"): \n-            async for subscribed_channel in self.services.channel_member.find(user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n+        if request.session.get(\"uid\"):\n+            async for subscribed_channel in self.services.channel_member.find(\n+                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n                 item = {}\n-                other_user = await self.services.channel_member.get_other_dm_user(subscribed_channel[\"channel_uid\"], request.session.get(\"uid\"))\n+                other_user = await self.services.channel_member.get_other_dm_user(\n+                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n+                )\n                 parent_object = await subscribed_channel.get_channel()\n-                last_message =await parent_object.get_last_message()\n-                color = None \n+                last_message = await parent_object.get_last_message()\n+                color = None\n                 if last_message:\n                     last_message_user = await last_message.get_user()\n                     color = last_message_user[\"color\"]\n-                item['color'] = color\n+                item[\"color\"] = color\n                 item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n                 item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n                 if other_user:\n@@ -193,19 +210,22 @@ class Application(BaseApplication):\n                 else:\n                     item[\"name\"] = subscribed_channel[\"label\"]\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                item['new_count'] = subscribed_channel['new_count'] \n-                \n+                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n+\n                 print(item)\n                 channels.append(item)\n-            \n-            channels.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n-            if not 'channels' in context:\n-                context['channels'] = channels\n-            if not 'user' in context:\n-                context['user'] = await self.services.user.get(uid=request.session.get(\"uid\"))\n+\n+            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+            if \"channels\" not in context:\n+                context[\"channels\"] = channels\n+            if \"user\" not in context:\n+                context[\"user\"] = await self.services.user.get(\n+                    uid=request.session.get(\"uid\")\n+                )\n \n         return await super().render_template(template, request, context)\n \n+\n executor = ThreadPoolExecutor(max_workers=200)\n \n loop = asyncio.get_event_loop()\n@@ -213,8 +233,10 @@ loop.set_default_executor(executor)\n \n \n+\n async def main():\n     await web._run_app(app, port=8081, host=\"0.0.0.0\")\n \n+\n if __name__ == \"__main__\":\n     asyncio.run(main())\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 1b7eb6b..b254756 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,31 +1,39 @@\n import asyncio\n-import json \n \n from snek.app import app\n \n+\n async def fix_message(message):\n-    message = dict(\n-        uid=message['uid'],\n-        user_uid=message['user_uid'],\n-        text=message['message'],\n-        sent=message['created_at']\n-    )\n-    user = await app.services.user.get(uid=message['user_uid'])\n-    message['user'] = user and user['username'] or None\n-    return (message['user'] or '') + ': ' + (message['text'] or '')\n+    message = {\n+        \"uid\": message[\"uid\"],\n+        \"user_uid\": message[\"user_uid\"],\n+        \"text\": message[\"message\"],\n+        \"sent\": message[\"created_at\"],\n+    }\n+    user = await app.services.user.get(uid=message[\"user_uid\"])\n+    message[\"user\"] = user and user[\"username\"] or None\n+    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n+\n \n async def dump_public_channels():\n     result = []\n-    for channel in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):\n+    for channel in app.db[\"channel\"].find(\n+        is_private=False, is_listed=True, tag=\"public\"\n+    ):\n         print(f\"Dumping channel: {channel['label']}.\")\n-        result += [await fix_message(record) for record in app.db['channel_message'].find(channel_uid=channel['uid'],order_by='created_at')]\n+        result += [\n+            await fix_message(record)\n+            for record in app.db[\"channel_message\"].find(\n+                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n+            )\n+        ]\n         print(\"Dump succesfull!\")\n     print(\"Converting to json.\")\n     print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.txt\",\"w\") as f:\n-        f.write('\\n\\n'.join(result))\n+    with open(\"dump.txt\", \"w\") as f:\n+        f.write(\"\\n\\n\".join(result))\n     print(\"Dump written to dump.json\")\n-    \n \n-if __name__ == '__main__':\n+\n+if __name__ == \"__main__\":\n     asyncio.run(dump_public_channels())\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex 6f431f0..7e946b9 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -16,4 +16,3 @@ class SearchUserForm(Form):\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n     )\n-\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex e4c67b0..96053ea 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -3,10 +3,10 @@ import functools\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n+from snek.mapper.drive import DriveMapper\n+from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n-from snek.mapper.drive import DriveMapper \n-from snek.mapper.drive_item import DriveItemMapper\n from snek.system.object import Object\n \n \ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex 970788a..c92c687 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -3,5 +3,5 @@ from snek.system.mapper import BaseMapper\n \n \n class DriveMapper(BaseMapper):\n-    table_name = 'drive'\n-    model_class = DriveModel \n+    table_name = \"drive\"\n+    model_class = DriveModel\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex c35afe1..3d17a61 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,7 +1,8 @@\n+from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-from snek.model.drive_item import DriveItemModel \n+\n \n class DriveItemMapper(BaseMapper):\n-    \n+\n     model_class = DriveItemModel\n-    table_name = 'drive_item'\n+    table_name = \"drive_item\"\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 649299e..183ddb0 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -12,11 +12,16 @@ class ChannelModel(BaseModel):\n     index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n-    async def get_last_message(self)->ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",dict(channel_uid=self['uid'])):\n-            \n-            return await self.app.services.channel_message.get(uid=model['uid'])\n+    async def get_last_message(self) -> ChannelMessageModel:\n+        async for model in self.app.services.channel_message.query(\n+            \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+            {\"channel_uid\": self[\"uid\"]},\n+        ):\n+\n+            return await self.app.services.channel_message.get(uid=model[\"uid\"])\n         return None\n \n     async def get_members(self):\n-        return await self.app.services.channel_member.find(channel_uid=self['uid'],deleted_at=None,is_banned=False)\n+        return await self.app.services.channel_member.find(\n+            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n+        )\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 9689fa5..54b0418 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -16,25 +16,26 @@ class ChannelMemberModel(BaseModel):\n     new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n \n     async def get_user(self):\n-        return await self.app.services.user.get(uid=self['user_uid'])\n-    \n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n     async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self['channel_uid'])\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n \n     async def get_name(self):\n         channel = await self.get_channel()\n         if channel[\"tag\"] == \"dm\":\n             user = await self.get_other_dm_user()\n-            return user['nick']\n-        return channel['name'] or self['label']\n+            return user[\"nick\"]\n+        return channel[\"name\"] or self[\"label\"]\n \n     async def get_other_dm_user(self):\n         channel = await self.get_channel()\n         if channel[\"tag\"] != \"dm\":\n             return None\n-        \n-        async for model in self.app.services.channel_member.find(channel_uid=channel['uid']):\n-            if model[\"uid\"] != self['uid']:\n+\n+        async for model in self.app.services.channel_member.find(\n+            channel_uid=channel[\"uid\"]\n+        ):\n+            if model[\"uid\"] != self[\"uid\"]:\n                 return await self.app.services.user.get(uid=model[\"user_uid\"])\n         return await self.get_user()\n-        \ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 4b84fdc..524a8a4 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -8,8 +8,8 @@ class ChannelMessageModel(BaseModel):\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n \n-    async def get_user(self)->UserModel:\n+    async def get_user(self) -> UserModel:\n         return await self.app.services.user.get(uid=self[\"user_uid\"])\n-    \n+\n     async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n\\ No newline at end of file\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex 3936d97..df17d0f 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,13 +1,14 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n \n \n class DriveModel(BaseModel):\n \n     user_uid = ModelField(name=\"user_uid\", required=True)\n-    name = ModelField(name='name', required=False, type=str) \n+    name = ModelField(name=\"name\", required=False, type=str)\n \n     @property\n     async def items(self):\n-        async for drive_item in self.app.services.drive_item.find(drive_uid=self['uid']):\n+        async for drive_item in self.app.services.drive_item.find(\n+            drive_uid=self[\"uid\"]\n+        ):\n             yield drive_item\n-\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 728cd89..6e28f84 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,18 +1,20 @@\n-from snek.system.model import BaseModel,ModelField \n import mimetypes\n \n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveItemModel(BaseModel):\n-    drive_uid = ModelField(name=\"drive_uid\", required=True,kind=str)\n-    name = ModelField(name=\"name\", required=True,kind=str)\n-    path = ModelField(name=\"path\", required=True,kind=str)\n-    file_type = ModelField(name=\"file_type\", required=True,kind=str) \n-    file_size = ModelField(name=\"file_size\", required=True,kind=int)\n-    \n+    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n+    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+\n     @property\n     def extension(self):\n-        return self['name'].split('.')[-1]\n-    \n-    @property \n+        return self[\"name\"].split(\".\")[-1]\n+\n+    @property\n     def mime_type(self):\n-        mimetype,_ = mimetypes.guess_type(self['name'])\n-        return mimetype \n+        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n+        return mimetype\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 0621ecf..a35d890 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -18,10 +18,7 @@ class UserModel(BaseModel):\n         regex=r\"^[a-zA-Z0-9_-+/]+$\",\n     )\n     color = ModelField(\n-        name =\"color\",\n-        required=True,\n-        kind=str\n     )\n     email = ModelField(\n         name=\"email\",\n@@ -33,5 +30,7 @@ class UserModel(BaseModel):\n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n     async def get_channel_members(self):\n-        async for channel_member in self.app.services.channel_member.find(user_uid=self['uid'],is_banned=False,deleted_at=None):\n+        async for channel_member in self.app.services.channel_member.find(\n+            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n+        ):\n             yield channel_member\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex e521c7b..4059f77 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -4,12 +4,12 @@ from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n from snek.service.chat import ChatService\n+from snek.service.drive import DriveService\n+from snek.service.drive_item import DriveItemService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n-from snek.service.drive import DriveService\n-from snek.service.drive_item import DriveItemService\n from snek.system.object import Object\n \n \n@@ -26,7 +26,7 @@ def get_services(app):\n             \"notification\": NotificationService(app=app),\n             \"util\": UtilService(app=app),\n             \"drive\": DriveService(app=app),\n-            \"drive_item\": DriveItemService(app=app)\n+            \"drive_item\": DriveItemService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 95ceef7..b90e66f 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,30 +1,28 @@\n+from datetime import datetime\n+\n+from snek.system.model import now\n from snek.system.service import BaseService\n-from datetime import datetime \n \n-from snek.system.model import now \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n-    async def get(\n-        self,\n-        uid=None,\n-        **kwargs):\n+    async def get(self, uid=None, **kwargs):\n         if uid:\n-            kwargs['uid'] = uid \n+            kwargs[\"uid\"] = uid\n             result = await super().get(**kwargs)\n             if result:\n-                return result \n-            del kwargs['uid']\n-            kwargs['name'] = uid \n+                return result\n+            del kwargs[\"uid\"]\n+            kwargs[\"name\"] = uid\n             result = await super().get(**kwargs)\n             if result:\n                 return result\n             result = await super().get(**kwargs)\n             if result:\n                 return result\n-            return None \n+            return None\n         return await super().get(**kwargs)\n \n     async def create(\n@@ -53,38 +51,34 @@ class ChannelService(BaseService):\n         raise Exception(f\"Failed to create channel: {model.errors}.\")\n \n     async def get_dm(self, user1, user2):\n-        channel_member = await self.services.channel_member.get_dm(\n-            user1, user2 \n-        )\n+        channel_member = await self.services.channel_member.get_dm(user1, user2)\n         if channel_member:\n             return await self.get(uid=channel_member[\"channel_uid\"])\n-        channel = await self.create(\n-            \"DM\", user1, tag=\"dm\"            \n-        )\n-        await self.services.channel_member.create_dm(\n-            channel[\"uid\"], user1, user2            \n-        )\n-        return channel \n+        channel = await self.create(\"DM\", user1, tag=\"dm\")\n+        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n+        return channel\n \n     async def get_users(self, channel_uid):\n-        users = []\n         async for channel_member in self.services.channel_member.find(\n             channel_uid=channel_uid,\n             is_banned=False,\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            user =  await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if user:\n                 yield user\n+\n     async def get_online_users(self, channel_uid):\n-        users = []\n         async for user in self.get_users(channel_uid):\n             if not user[\"last_ping\"]:\n                 continue\n \n-            if (datetime.fromisoformat(now()) - datetime.fromisoformat(user[\"last_ping\"])).total_seconds() < 20:\n-                yield user \n+            if (\n+                datetime.fromisoformat(now())\n+                - datetime.fromisoformat(user[\"last_ping\"])\n+            ).total_seconds() < 20:\n+                yield user\n \n     async def get_for_user(self, user_uid):\n         async for channel_member in self.services.channel_member.find(\n@@ -93,7 +87,7 @@ class ChannelService(BaseService):\n             deleted_at=None,\n         ):\n             channel = await self.get(uid=channel_member[\"channel_uid\"])\n-            yield channel \n+            yield channel\n \n     async def ensure_public_channel(self, created_by_uid):\n         model = await self.get(is_listed=True, tag=\"public\")\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 5c6c7ee..16d1887 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -6,10 +6,7 @@ class ChannelMemberService(BaseService):\n     mapper_name = \"channel_member\"\n \n     async def mark_as_read(self, channel_uid, user_uid):\n-        channel_member =  await self.get(\n-            channel_uid=channel_uid,\n-            user_uid=user_uid\n-        )\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         channel_member[\"new_count\"] = 0\n         return await self.save(channel_member)\n \n@@ -24,7 +21,7 @@ class ChannelMemberService(BaseService):\n     ):\n         model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n         if model:\n-            if model['is_banned']:\n+            if model[\"is_banned\"]:\n                 return False\n             return model\n         model = await self.new()\n@@ -39,30 +36,32 @@ class ChannelMemberService(BaseService):\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel member: {model.errors}.\")\n-    \n-    async def get_dm(self,from_user, to_user):\n-        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n+\n+    async def get_dm(self, from_user, to_user):\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n             return model\n         if not from_user == to_user:\n-            return None \n-        async for model in self.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \" ,dict(from_user=from_user, to_user=to_user)):\n-           \n-            return model \n-    \n+            return None\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+\n+            return model\n+\n     async def get_other_dm_user(self, channel_uid, user_uid):\n         channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel = await self.services.channel.get(uid=channel_member['channel_uid'])\n+        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n         if channel[\"tag\"] != \"dm\":\n             return None\n         async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            if model[\"uid\"] != channel_member['uid']:\n+            if model[\"uid\"] != channel_member[\"uid\"]:\n                 return await self.services.user.get(uid=model[\"user_uid\"])\n \n-    async def create_dm(self,channel_uid, from_user_uid, to_user_uid):\n+    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n         result = await self.create(channel_uid, from_user_uid)\n         await self.create(channel_uid, to_user_uid)\n-        return result \n-\n-\n-\n-\n+        return result\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 9683631..f8a000f 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,71 +1,93 @@\n from snek.system.service import BaseService\n-import jinja2 \n+\n \n class ChannelMessageService(BaseService):\n     mapper_name = \"channel_message\"\n \n     async def create(self, channel_uid, user_uid, message):\n         model = await self.new()\n-        \n+\n         model[\"channel_uid\"] = channel_uid\n         model[\"user_uid\"] = user_uid\n         model[\"message\"] = message\n-                \n-        context = {\n-            \n-        }\n-        \n-        \n-        record = model.record \n+\n+        context = {}\n+\n+        record = model.record\n         context.update(record)\n         user = await self.app.services.user.get(uid=user_uid)\n-        context.update(dict(\n-            user_uid=user['uid'],\n-            username=user['username'],\n-            user_nick=user['nick'],\n-            color=user['color']\n-        ))\n+        context.update(\n+            {\n+                \"user_uid\": user[\"uid\"],\n+                \"username\": user[\"username\"],\n+                \"user_nick\": user[\"nick\"],\n+                \"color\": user[\"color\"],\n+            }\n+        )\n         try:\n             template = self.app.jinja2_env.get_template(\"message.html\")\n             model[\"html\"] = template.render(**context)\n         except Exception as ex:\n-            print(ex,flush=True)\n-        \n+            print(ex, flush=True)\n+\n         if await self.save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n-    \n+\n     async def to_extended_dict(self, message):\n         user = await self.services.user.get(uid=message[\"user_uid\"])\n         if not user:\n             return {}\n         return {\n             \"uid\": message[\"uid\"],\n-            \"color\": user['color'],\n+            \"color\": user[\"color\"],\n             \"user_uid\": message[\"user_uid\"],\n             \"channel_uid\": message[\"channel_uid\"],\n-            \"user_nick\": user['nick'],\n+            \"user_nick\": user[\"nick\"],\n             \"message\": message[\"message\"],\n             \"created_at\": message[\"created_at\"],\n-            \"html\": message['html'],\n-            \"username\": user['username'] \n+            \"html\": message[\"html\"],\n+            \"username\": user[\"username\"],\n         }\n \n-    async def offset(self, channel_uid, page=0, timestamp = None, page_size=30):\n+    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n         results = []\n-        offset = page * page_size \n+        offset = page * page_size\n         try:\n             if timestamp:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset, timestamp=timestamp)):\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n                     results.append(model)\n             elif page > 0:\n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset,timestamp=timestamp )):\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n                     results.append(model)\n-            else: \n-                async for model in self.query(\"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",dict(channel_uid=channel_uid, page_size=page_size, offset=offset)):\n+            else:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                    },\n+                ):\n                     results.append(model)\n \n-        except: \n+        except:\n             pass\n-        results.sort(key=lambda x: x['created_at'])\n-        return results \n+        results.sort(key=lambda x: x[\"created_at\"])\n+        return results\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 8b1f8ad..388d5c0 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,40 +1,39 @@\n-\n-\n-\n from snek.system.model import now\n from snek.system.service import BaseService\n \n \n class ChatService(BaseService):\n \n-    async def send(self,user_uid, channel_uid, message):\n+    async def send(self, user_uid, channel_uid, message):\n         channel = await self.services.channel.get(uid=channel_uid)\n         if not channel:\n             raise Exception(\"Channel not found.\")\n         channel_message = await self.services.channel_message.create(\n-            channel_uid, \n-            user_uid, \n-            message\n+            channel_uid, user_uid, message\n         )\n         channel_message_uid = channel_message[\"uid\"]\n-        \n-        \n+\n         user = await self.services.user.get(uid=user_uid)\n-        channel['last_message_on'] = now()\n+        channel[\"last_message_on\"] = now()\n         await self.services.channel.save(channel)\n-         \n-        await self.services.socket.broadcast(channel_uid, dict(\n-            message=channel_message[\"message\"],\n-            html=channel_message[\"html\"],\n-            user_uid=user_uid,\n-            color=user['color'],\n-            channel_uid=channel_uid,\n-            created_at=channel_message[\"created_at\"], \n-            updated_at=None,\n-            username=user['username'],\n-            uid=channel_message['uid'],\n-            user_nick=user['nick']\n-        ))\n-        await self.app.create_task(self.services.notification.create_channel_message(channel_message_uid))\n-       \n+\n+        await self.services.socket.broadcast(\n+            channel_uid,\n+            {\n+                \"message\": channel_message[\"message\"],\n+                \"html\": channel_message[\"html\"],\n+                \"user_uid\": user_uid,\n+                \"color\": user[\"color\"],\n+                \"channel_uid\": channel_uid,\n+                \"created_at\": channel_message[\"created_at\"],\n+                \"updated_at\": None,\n+                \"username\": user[\"username\"],\n+                \"uid\": channel_message[\"uid\"],\n+                \"user_nick\": user[\"nick\"],\n+            },\n+        )\n+        await self.app.create_task(\n+            self.services.notification.create_channel_message(channel_message_uid)\n+        )\n+\n         return True\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex b90a959..38035c7 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -2,14 +2,90 @@ from snek.system.service import BaseService\n \n \n class DriveService(BaseService):\n-    \n+\n     mapper_name = \"drive\"\n \n-    EXTENSIONS_PICTURES = [\"jpg\",\"jpeg\",\"png\",\"gif\",\"svg\",\"webp\",\"tiff\"]\n-    EXTENSIONS_VIDEOS = [\"mp4\",\"m4v\",\"mov\",\"wmv\",\"webm\",\"mkv\",\"mpg\",\"mpeg\",\"avi\",\"ogv\",\"ogg\",\"flv\",\"3gp\",\"3g2\"]\n-    EXTENSIONS_ARCHIVES = [\"zip\",\"rar\",\"7z\",\"tar\",\"tar.gz\",\"tar.xz\",\"tar.bz2\",\"tar.lzma\",\"tar.lz\"]\n-    EXTENSIONS_AUDIO = [\"mp3\",\"wav\",\"ogg\",\"flac\",\"m4a\",\"wma\",\"aac\",\"opus\",\"aiff\",\"au\",\"mid\",\"midi\"]\n-    EXTENSIONS_DOCS = [\"pdf\",\"doc\",\"docx\",\"xls\",\"xlsx\",\"ppt\",\"pptx\",\"txt\",\"md\",\"json\",\"csv\",\"xml\",\"html\",\"css\",\"js\",\"py\",\"sql\",\"rs\",\"toml\",\"yml\",\"yaml\",\"ini\",\"conf\",\"config\",\"log\",\"csv\",\"tsv\",\"java\",\"cs\",\"csproj\",\"scss\",\"less\",\"sass\",\"json\",\"lock\",\"lock.json\",\"jsonl\"]\n+    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n+    EXTENSIONS_VIDEOS = [\n+        \"mp4\",\n+        \"m4v\",\n+        \"mov\",\n+        \"wmv\",\n+        \"webm\",\n+        \"mkv\",\n+        \"mpg\",\n+        \"mpeg\",\n+        \"avi\",\n+        \"ogv\",\n+        \"ogg\",\n+        \"flv\",\n+        \"3gp\",\n+        \"3g2\",\n+    ]\n+    EXTENSIONS_ARCHIVES = [\n+        \"zip\",\n+        \"rar\",\n+        \"7z\",\n+        \"tar\",\n+        \"tar.gz\",\n+        \"tar.xz\",\n+        \"tar.bz2\",\n+        \"tar.lzma\",\n+        \"tar.lz\",\n+    ]\n+    EXTENSIONS_AUDIO = [\n+        \"mp3\",\n+        \"wav\",\n+        \"ogg\",\n+        \"flac\",\n+        \"m4a\",\n+        \"wma\",\n+        \"aac\",\n+        \"opus\",\n+        \"aiff\",\n+        \"au\",\n+        \"mid\",\n+        \"midi\",\n+    ]\n+    EXTENSIONS_DOCS = [\n+        \"pdf\",\n+        \"doc\",\n+        \"docx\",\n+        \"xls\",\n+        \"xlsx\",\n+        \"ppt\",\n+        \"pptx\",\n+        \"txt\",\n+        \"md\",\n+        \"json\",\n+        \"csv\",\n+        \"xml\",\n+        \"html\",\n+        \"css\",\n+        \"js\",\n+        \"py\",\n+        \"sql\",\n+        \"rs\",\n+        \"toml\",\n+        \"yml\",\n+        \"yaml\",\n+        \"ini\",\n+        \"conf\",\n+        \"config\",\n+        \"log\",\n+        \"csv\",\n+        \"tsv\",\n+        \"java\",\n+        \"cs\",\n+        \"csproj\",\n+        \"scss\",\n+        \"less\",\n+        \"sass\",\n+        \"json\",\n+        \"lock\",\n+        \"lock.json\",\n+        \"jsonl\",\n+    ]\n \n     async def get_drive_name_by_extension(self, extension):\n         if extension.startswith(\".\"):\n@@ -26,54 +102,52 @@ class DriveService(BaseService):\n             return \"Documents\"\n         return \"My Drive\"\n \n-    async def get_drive_by_extension(self,user_uid, extension):\n+    async def get_drive_by_extension(self, user_uid, extension):\n         name = await self.get_drive_name_by_extension(extension)\n-        return await self.get_or_create(user_uid=user_uid,name=name)\n+        return await self.get_or_create(user_uid=user_uid, name=name)\n \n-    async def get_by_user(self, user_uid,name=None):\n-        kwargs = dict(\n-            user_uid = user_uid\n-        )\n+    async def get_by_user(self, user_uid, name=None):\n+        kwargs = {\"user_uid\": user_uid}\n         async for model in self.find(**kwargs):\n             if not name:\n-                yield model \n-            elif model['name'] == name:\n                 yield model\n-            elif not model['name'] and name == 'My Drive':\n-                model['name'] = 'My Drive'\n+            elif model[\"name\"] == name:\n+                yield model\n+            elif not model[\"name\"] and name == \"My Drive\":\n+                model[\"name\"] = \"My Drive\"\n                 await self.save(model)\n-                yield model \n+                yield model\n \n-    async def get_or_create(self, user_uid,name=None,extensions=None):\n-        kwargs = dict(user_uid=user_uid)\n+    async def get_or_create(self, user_uid, name=None, extensions=None):\n+        kwargs = {\"user_uid\": user_uid}\n         if name:\n-            kwargs['name'] = name\n+            kwargs[\"name\"] = name\n         async for model in self.get_by_user(**kwargs):\n-            return model  \n+            return model\n \n         model = await self.new()\n-        model['user_uid'] = user_uid\n-        model['name'] = name \n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n         await self.save(model)\n-        return model \n+        return model\n \n     async def prepare_default_drives(self):\n         async for drive_item in self.services.drive_item.find():\n             extension = drive_item.extension\n-            drive = await self.get_drive_by_extension(drive_item['user_uid'],extension)\n-            if not drive_item['drive_uid'] == drive['uid']:\n-                drive_item['drive_uid'] = drive['uid']\n+            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n+            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n+                drive_item[\"drive_uid\"] = drive[\"uid\"]\n                 await self.services.drive_item.save(drive_item)\n-            \n+\n     async def prepare_default_drives_for_user(self, user_uid):\n-        await self.get_or_create(user_uid=user_uid,name=\"My Drive\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Shared Drive\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Pictures\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Videos\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Archives\")\n-        await self.get_or_create(user_uid=user_uid,name=\"Documents\")\n+        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n \n     async def prepare_all(self):\n         await self.prepare_default_drives()\n         async for user in self.services.user.find():\n-            await self.prepare_default_drives_for_user(user['uid']) \n+            await self.prepare_default_drives_for_user(user[\"uid\"])\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 05a7da8..ce747c1 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,19 +1,19 @@\n-from snek.system.service import BaseService \n+from snek.system.service import BaseService\n \n \n class DriveItemService(BaseService):\n \n     mapper_name = \"drive_item\"\n \n-    async def create(self, drive_uid, name, path, type_,size):\n-        model = await self.new() \n-        model['drive_uid'] = drive_uid \n-        model['name'] = name \n-        model['path'] = str(path) \n-        model['extension'] = str(name).split(\".\")[-1]\n-        model['file_type'] = type_ \n-        model['file_size'] = size\n+    async def create(self, drive_uid, name, path, type_, size):\n+        model = await self.new()\n+        model[\"drive_uid\"] = drive_uid\n+        model[\"name\"] = name\n+        model[\"path\"] = str(path)\n+        model[\"extension\"] = str(name).split(\".\")[-1]\n+        model[\"file_type\"] = type_\n+        model[\"file_size\"] = size\n         if await self.save(model):\n-            return model \n+            return model\n         errors = await model.errors\n         raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 1392044..a22e8ae 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,5 +1,6 @@\n-from snek.system.service import BaseService\n from snek.system.model import now\n+from snek.system.service import BaseService\n+\n \n class NotificationService(BaseService):\n     mapper_name = \"notification\"\n@@ -7,13 +8,16 @@ class NotificationService(BaseService):\n     async def mark_as_read(self, user_uid, channel_message_uid):\n         model = await self.get(user_uid, object_uid=channel_message_uid)\n         if not model:\n-            return False \n-        model['read_at'] = now()\n+            return False\n+        model[\"read_at\"] = now()\n         await self.save(model)\n-        return True \n-    \n-    async def get_unread_stats(self,user_uid):\n-        records = await self.query(\"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",dict(user_uid=user_uid))\n+        return True\n+\n+    async def get_unread_stats(self, user_uid):\n+        await self.query(\n+            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n+            {\"user_uid\": user_uid},\n+        )\n \n     async def create(self, object_uid, object_type, user_uid, message):\n         model = await self.new()\n@@ -37,10 +41,10 @@ class NotificationService(BaseService):\n             is_muted=False,\n             deleted_at=None,\n         ):\n-            if not channel_member['new_count']:\n-                channel_member['new_count'] = 0\n-            channel_member['new_count'] += 1\n-            \n+            if not channel_member[\"new_count\"]:\n+                channel_member[\"new_count\"] = 0\n+            channel_member[\"new_count\"] += 1\n+\n             usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n             if not usr:\n                 continue\n@@ -55,7 +59,7 @@ class NotificationService(BaseService):\n             )\n             try:\n                 await self.save(model)\n-            except Exception as ex:\n+            except Exception:\n                 raise Exception(f\"Failed to create notification: {model.errors}.\")\n \n         self.app.db.commit()\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex d941321..072a86f 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,6 +1,4 @@\n from snek.model.user import UserModel\n-\n-\n from snek.system.service import BaseService\n \n \n@@ -9,28 +7,27 @@ class SocketService(BaseService):\n     class Socket:\n         def __init__(self, ws, user: UserModel):\n             self.ws = ws\n-            self.is_connected = True \n-            self.user = user \n+            self.is_connected = True\n+            self.user = user\n \n         async def send_json(self, data):\n             if not self.is_connected:\n-                return False \n+                return False\n             try:\n                 await self.ws.send_json(data)\n             except Exception as ex:\n-                print(ex,flush=True)\n+                print(ex, flush=True)\n                 self.is_connected = False\n-            return True \n+            return True\n \n         async def close(self):\n             if not self.is_connected:\n-                return True \n-            \n+                return True\n+\n             await self.ws.close()\n             self.is_connected = False\n-            \n-            return True  \n \n+            return True\n \n     def __init__(self, app):\n         super().__init__(app)\n@@ -42,32 +39,31 @@ class SocketService(BaseService):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n         if not self.users.get(user_uid):\n-            self.users[user_uid] = set() \n+            self.users[user_uid] = set()\n         self.users[user_uid].add(s)\n \n-    async def subscribe(self, ws,channel_uid, user_uid):\n+    async def subscribe(self, ws, channel_uid, user_uid):\n         return\n-        if not channel_uid in self.subscriptions:\n+        if channel_uid not in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n-        s = self.Socket(ws,await self.app.services.user.get(uid=user_uid))\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.subscriptions[channel_uid].add(s)\n \n     async def send_to_user(self, user_uid, message):\n-        count = 0 \n-        for s in self.users.get(user_uid,[]):\n+        count = 0\n+        for s in self.users.get(user_uid, []):\n             if await s.send_json(message):\n-               count += 1 \n-        return count  \n+                count += 1\n+        return count\n \n     async def broadcast(self, channel_uid, message):\n-        count = 0\n-        async for channel_member in self.app.services.channel_member.find(channel_uid=channel_uid):\n-            await self.send_to_user(channel_member[\"user_uid\"],message)\n-        return True \n-    \n+        async for channel_member in self.app.services.channel_member.find(\n+            channel_uid=channel_uid\n+        ):\n+            await self.send_to_user(channel_member[\"user_uid\"], message)\n+        return True\n+\n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n             self.sockets.remove(s)\n-            \n-    \ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 02707b0..6055403 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,16 +1,18 @@\n+import pathlib\n+\n from snek.system import security\n from snek.system.service import BaseService\n \n \n class UserService(BaseService):\n     mapper_name = \"user\"\n-    \n+\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\n             raise []\n         results = []\n-        async for result in self.find(username=dict(ilike='%' + query + '%'), **kwargs):\n+        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)\n         return results\n \n@@ -23,17 +25,32 @@ class UserService(BaseService):\n         return True\n \n     async def save(self, user):\n-        if not user['color']:\n-            user['color'] = await self.services.util.random_light_hex_color()\n+        if not user[\"color\"]:\n+            user[\"color\"] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+    async def authenticate(self, username, password):\n+        print(username, password, flush=True)\n+        success = await self.validate_login(username, password)\n+        print(success, flush=True)\n+        if not success:\n+            return None\n+\n+        model = await self.get(username=username, deleted_at=None)\n+        return model\n+\n+    async def get_home_folder(self, user_uid):\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        if not folder.exists():\n+            folder.mkdir(parents=True, exist_ok=True)\n+        return folder\n \n     async def register(self, email, username, password):\n         if await self.exists(username=username):\n             raise Exception(\"User already exists.\")\n         model = await self.new()\n         model[\"nick\"] = username\n-        model['color'] = await self.services.util.random_light_hex_color()\n+        model[\"color\"] = await self.services.util.random_light_hex_color()\n         model.email.value = email\n         model.username.value = username\n         model.password.value = await security.hash(password)\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex 5550a8c..b620d9c 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,15 +1,14 @@\n import random\n \n-\n from snek.system.service import BaseService\n \n \n class UtilService(BaseService):\n-    \n+\n     async def random_light_hex_color(self):\n-   \n+\n         r = random.randint(128, 255)\n         g = random.randint(128, 255)\n         b = random.randint(128, 255)\n-        \n\\ No newline at end of file\n+\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex cd9484d..e97fdc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -20,13 +20,13 @@ class Cache:\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n             return None\n         self.lru.insert(0, args)\n         while len(self.lru) > self.max_items:\n             self.cache.pop(self.lru[-1])\n             self.lru.pop()\n         return self.cache[args]\n \n     def json_default(self, value):\n@@ -61,7 +61,7 @@ class Cache:\n \n         if is_new:\n             self.version += 1\n \n     async def delete(self, args):\n         if args in self.cache:\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex ca603d8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,7 +1,7 @@\n \n from types import SimpleNamespace\n-from html import escape\n+\n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n@@ -24,21 +24,21 @@ class MarkdownRenderer(HTMLRenderer):\n     def _escape(self, str):\n \n-    def get_lexer(self, lang, default='bash'):\n+    def get_lexer(self, lang, default=\"bash\"):\n         try:\n             return get_lexer_by_name(lang, stripall=True)\n         except:\n             return get_lexer_by_name(default, stripall=True)\n-    \n+\n     def block_code(self, code, lang=None, info=None):\n         if not lang:\n             lang = info\n         if not lang:\n-            lang = 'bash'\n+            lang = \"bash\"\n         lexer = self.get_lexer(lang)\n         formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n         result = highlight(code, lexer, formatter)\n-        return result \n+        return result\n \n     def render(self):\n         markdown_string = self.app.template_path.joinpath(self.template).read_text()\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 2946262..1a6c7e6 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -20,7 +20,9 @@ async def no_cors_middleware(request, handler):\n async def cors_allow_middleware(request, handler):\n     response = await handler(request)\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, OPTIONS, PUT, DELETE\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = (\n+        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n+    )\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n     return response\n@@ -28,17 +30,12 @@ async def cors_allow_middleware(request, handler):\n \n @web.middleware\n async def cors_middleware(request, handler):\n-    if request.method == \"OPTIONS\":\n-        response = web.Response()\n-        response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Methods\"] = (\n-            \"GET, POST, PUT, DELETE, OPTIONS\"\n-        )\n-        response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-        response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-        return response\n+    if request.headers.get(\"Allow\"):\n+        return await handler(request)\n \n     response = await handler(request)\n+    if request.headers.get(\"Allow\"):\n+        return response\n     response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n     response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n     response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex 193bbb7..e0e5542 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,24 +1,27 @@\n import cProfile\n import pstats\n-import sys \n+import sys\n+\n from aiohttp import web\n+\n profiler = None\n-import io \n+import io\n \n \n @web.middleware\n async def profile_middleware(request, handler):\n-    global profiler \n+    global profiler\n     if not profiler:\n         profiler = cProfile.Profile()\n     profiler.enable()\n     response = await handler(request)\n     profiler.disable()\n     stats = pstats.Stats(profiler, stream=sys.stdout)\n-    stats.sort_stats('cumulative')\n-    stats.print_stats() \n+    stats.sort_stats(\"cumulative\")\n+    stats.print_stats()\n     return response\n \n+\n async def profiler_handler(request):\n     output = io.StringIO()\n     stats = pstats.Stats(profiler, stream=output)\n@@ -27,17 +30,17 @@ async def profiler_handler(request):\n     stats.print_stats()\n     return web.Response(text=output.getvalue())\n \n+\n class Profiler:\n \n     def __init__(self):\n-        global profiler \n+        global profiler\n         if profiler is None:\n             profiler = cProfile.Profile()\n         self.profiler = profiler\n \n     async def __aenter__(self):\n-        self.profiler.enable()    \n-    \n+        self.profiler.enable()\n+\n     async def __aexit__(self, *args, **kwargs):\n         self.profiler.disable()\n-\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex d65f947..c6d2afc 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -58,7 +58,7 @@ class BaseService:\n         raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n \n     async def find(self, **kwargs):\n-        if not \"_limit\" in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n             kwargs[\"_limit\"] = 60\n         async for model in self.mapper.find(**kwargs):\n             yield model\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 0e71b80..cff807b 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,15 +1,21 @@\n+import re\n from types import SimpleNamespace\n-from bs4 import BeautifulSoup\n-import re \n-import emoji\n \n+import emoji\n+from bs4 import BeautifulSoup\n from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n \n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\"en\": \":snek1:\",\"status\":2,\"E\":0.6, \"alias\":[\":snek1:\"]} \n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n+    \"en\": \":snek1:\",\n+    \"status\": 2,\n+    \"E\": 0.6,\n+    \"alias\": [\":snek1:\"],\n+}\n \n-emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+emoji.EMOJI_DATA[\n+    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n@@ -64,62 +70,91 @@ emoji.EMOJI_DATA[\"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n \u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\"\"\"] = {\"en\": \":a1:\",\"status\":2,\"E\":0.6, \"alias\":[\":a1:\"]}\n+\"\"\"\n+] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n+\n \n def set_link_target_blank(text):\n-    soup = BeautifulSoup(text, 'html.parser')\n+    soup = BeautifulSoup(text, \"html.parser\")\n \n-    for element in soup.find_all(\"a\"):  \n-        element.attrs['target'] = '_blank'\n-        element.attrs['rel'] = 'noopener noreferrer'\n-        element.attrs['referrerpolicy'] = 'no-referrer'\n-        element.attrs['href'] = element.attrs['href'].strip(\".\").strip(\",\")\n+    for element in soup.find_all(\"a\"):\n+        element.attrs[\"target\"] = \"_blank\"\n+        element.attrs[\"rel\"] = \"noopener noreferrer\"\n+        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n+        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n \n     return str(soup)\n \n+\n def embed_youtube(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        if (\n+            and \"?v=\" in element.attrs[\"href\"]\n+        ):\n             video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n+\n def embed_image(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n-        for extension in [\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\", \".svg\", \".bmp\", \".tiff\", \".ico\", \".heif\"]:\n-            if extension in element.attrs['href'].lower():\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".png\",\n+            \".jpg\",\n+            \".jpeg\",\n+            \".gif\",\n+            \".webp\",\n+            \".svg\",\n+            \".bmp\",\n+            \".tiff\",\n+            \".ico\",\n+            \".heif\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n                 embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n+\n def embed_media(text):\n-    soup = BeautifulSoup(text, 'html.parser') \n-    for element in soup.find_all(\"a\"):  \n-        for extension in [\".mp4\", \".mp3\", \".wav\", \".ogg\", \".webm\", \".flac\", \".aac\",\".mpg\",\".avi\",\".wmv\"]:\n-            if extension in element.attrs['href'].lower():\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".mp4\",\n+            \".mp3\",\n+            \".wav\",\n+            \".ogg\",\n+            \".webm\",\n+            \".flac\",\n+            \".aac\",\n+            \".mpg\",\n+            \".avi\",\n+            \".wmv\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n                 embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n-                element.replace_with(BeautifulSoup(embed_template, 'html.parser'))\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)\n \n \n-\n def linkify_https(text):\n-        return text \n+        return text\n \n-    soup = BeautifulSoup(text, 'html.parser')\n+    soup = BeautifulSoup(text, \"html.parser\")\n \n-    for element in soup.find_all(text=True):  \n+    for element in soup.find_all(text=True):\n         parent = element.parent\n-        if parent.name in ['a', 'script', 'style']:  \n+        if parent.name in [\"a\", \"script\", \"style\"]:\n             continue\n-        \n+\n         new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n-        element.replace_with(BeautifulSoup(new_text, 'html.parser'))\n+        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n \n     return set_link_target_blank(str(soup))\n \n@@ -140,8 +175,7 @@ class EmojiExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        return emoji.emojize(caller(),language='alias')\n-\n+        return emoji.emojize(caller(), language=\"alias\")\n \n \n class LinkifyExtension(Extension):\n@@ -170,6 +204,7 @@ class LinkifyExtension(Extension):\n         result = embed_youtube(result)\n         return result\n \n+\n class PythonExtension(Extension):\n     tags = {\"py3\"}\n \n@@ -186,26 +221,26 @@ class PythonExtension(Extension):\n         ).set_lineno(line_number)\n \n     def _to_html(self, md_file, caller):\n-        \n+\n         def fn(source):\n-            import subprocess \n-            import subprocess \n-            import pathlib \n-            from pathlib import Path \n-            import os \n-            import sys \n-            import requests\n+            import subprocess\n+\n             def system(command):\n                 if isinstance(command):\n                     command = command.split(\" \")\n-                from io import StringIO \n+                from io import StringIO\n+\n                 stdout = StringIO()\n-                subprocess.run(command,stderr=stdout,stdout=stdout,text=True)\n+                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n                 return stdout.getvalue()\n+\n             to_write = []\n+\n             def render(text):\n-                global to_write \n+                global to_write\n                 to_write.append(text)\n+\n             exec(source)\n             return \"\".join(to_write)\n+\n         return str(fn(caller()))\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 80dda17..4d3781e 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,30 +1,27 @@\n import asyncio\n-import aiohttp\n-import aiohttp.web\n import os\n import pty\n-import shlex\n import subprocess\n-import pathlib\n \n commands = {\n-    'alpine': 'docker run -it alpine /bin/sh',\n-    'r': 'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh',\n+    \"alpine\": \"docker run -it alpine /bin/sh\",\n+    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n }\n \n+\n class TerminalSession:\n-    def __init__(self,command):\n+    def __init__(self, command):\n         self.master, self.slave = pty.openpty()\n-        self.sockets =[]\n-        self.history = b''\n-        self.history_size = 1024*20\n+        self.sockets = []\n+        self.history = b\"\"\n+        self.history_size = 1024 * 20\n         self.process = subprocess.Popen(\n             command.split(\" \"),\n             stdin=self.slave,\n             stdout=self.slave,\n             stderr=self.slave,\n             bufsize=0,\n-            universal_newlines=True\n+            universal_newlines=True,\n         )\n \n     async def add_websocket(self, ws):\n@@ -35,11 +32,11 @@ class TerminalSession:\n         if len(self.sockets) > 1 and self.history:\n             start = 0\n             try:\n-                start = self.history.index(b'\\n')\n+                start = self.history.index(b\"\\n\")\n             except ValueError:\n-                pass \n+                pass\n             await ws.send_bytes(self.history[start:])\n-            return \n+            return\n         loop = asyncio.get_event_loop()\n         while True:\n             try:\n@@ -48,9 +45,10 @@ class TerminalSession:\n                     break\n                 self.history += data\n                 if len(self.history) > self.history_size:\n-                    self.history = self.history[:0-self.history_size]\n+                    self.history = self.history[: 0 - self.history_size]\n                 try:\n+                    for ws in self.sockets:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 2765642..4a6e7a1 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -12,9 +12,9 @@ class BaseView(web.View):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \n-    @property \n+    @property\n     def base_url(self):\n-        return str(self.request.url.with_path('').with_query(''))\n+        return str(self.request.url.with_path(\"\").with_query(\"\"))\n \n     @property\n     def app(self):\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex b03f922..aba57ae 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -26,12 +26,14 @@\n \n from snek.system.view import BaseView\n \n+\n class AboutHTMLView(BaseView):\n-   \n+\n     async def get(self):\n         return await self.render_template(\"about.html\")\n \n+\n class AboutMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"about.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex cbd973c..a85b876 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -23,11 +23,14 @@\n-from multiavatar import multiavatar\n import uuid\n+\n from aiohttp import web\n+from multiavatar import multiavatar\n+\n from snek.system.view import BaseView\n \n+\n class AvatarView(BaseView):\n     login_required = False\n \n@@ -36,6 +39,6 @@ class AvatarView(BaseView):\n         if uid == \"unique\":\n             uid = str(uuid.uuid4())\n         avatar = multiavatar.multiavatar(uid, True, None)\n-        response = web.Response(text=avatar, content_type='image/svg+xml')\n-        response.headers['Cache-Control'] = f'public, max-age={1337*42}'\n+        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n         return response\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex 592d1a2..bb63413 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,35 +1,37 @@\n-  \n-  \n-  \n-  \n-  \n+\n+\n+\n+\n+\n from snek.system.view import BaseView\n \n+\n class DocsHTMLView(BaseView):\n \n     async def get(self):\n         return await self.render_template(\"docs.html\")\n \n+\n class DocsMDView(BaseView):\n \n     async def get(self):\n-        return await self.render_template(\"docs.md\")\n\\ No newline at end of file\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 1026cf7..853cdb2 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,6 +1,8 @@\n-from snek.system.view import BaseView\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class DriveView(BaseView):\n \n     login_required = True\n@@ -14,21 +16,22 @@ class DriveView(BaseView):\n             drive_items = []\n             async for item in drive.items:\n                 record = item.record\n-                record['url'] = '/drive.bin/' + record['uid'] + '.' + item.extension\n+                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n                 drive_items.append(record)\n             return web.json_response(drive_items)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n-        \n         drives = []\n-        async for drive in self.services.drive.get_by_user(user['uid']):\n+        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n             record = drive.record\n-            record['items'] = []\n+            record[\"items\"] = []\n             async for item in drive.items:\n                 drive_item_record = item.record\n-                drive_item_record['url'] = '/drive.bin/' + drive_item_record['uid'] + '.' + item.extension\n-                record['items'].append(item.record)\n+                drive_item_record[\"url\"] = (\n+                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n+                )\n+                record[\"items\"].append(item.record)\n             drives.append(record)\n-        \n+\n         return web.json_response(drives)\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 3e62518..6ad6e70 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -12,6 +12,7 @@\n \n from snek.system.view import BaseView\n \n+\n class IndexView(BaseView):\n     async def get(self):\n-        return await self.render_template(\"index.html\")\n\\ No newline at end of file\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 5028a7a..be79328 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -7,9 +7,11 @@\n \n from aiohttp import web\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n \n+\n class LoginView(BaseFormView):\n     form = LoginForm\n \n@@ -18,17 +20,23 @@ class LoginView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"login.html\", {\"form\": await self.form(app=self.app).to_json()})\n+        return await self.render_template(\n+            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            user = await self.services.user.get(username=form['username'], deleted_at=None)\n+            user = await self.services.user.get(\n+                username=form[\"username\"], deleted_at=None\n+            )\n             await self.services.user.save(user)\n-            self.session.update({\n-                \"logged_in\": True,\n-                \"username\": user['username'],\n-                \"uid\": user[\"uid\"],\n-                \"color\": user[\"color\"]\n-            })\n+            self.session.update(\n+                {\n+                    \"logged_in\": True,\n+                    \"username\": user[\"username\"],\n+                    \"uid\": user[\"uid\"],\n+                    \"color\": user[\"color\"],\n+                }\n+            )\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 230d334..acf7c75 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -20,4 +20,4 @@ class LoginFormView(BaseFormView):\n             self.session[\"username\"] = form.username.value\n             self.session[\"uid\"] = form.uid.value\n             return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n\\ No newline at end of file\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 57b92a3..42016d8 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -15,10 +15,10 @@\n@@ -29,6 +29,7 @@\n \n \n from aiohttp import web\n+\n from snek.system.view import BaseView\n \n \n@@ -52,4 +53,4 @@ class LogoutView(BaseView):\n             del self.session[\"username\"]\n         except KeyError:\n             pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n\\ No newline at end of file\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 6e49506..7fbce9d 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -2,14 +2,16 @@\n \n \n \n \n from aiohttp import web\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterView(BaseFormView):\n     form = RegisterForm\n \n@@ -18,16 +20,20 @@ class RegisterView(BaseFormView):\n             return web.HTTPFound(\"/web.html\")\n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n-        return await self.render_template(\"register.html\", {\"form\": await self.form(app=self.app).to_json()})\n+        return await self.render_template(\n+            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n \n     async def submit(self, form):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session.update({\n-            \"uid\": result[\"uid\"],\n-            \"username\": result[\"username\"],\n-            \"logged_in\": True,\n-            \"color\": result[\"color\"]\n-        })\n-        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 5ce42a7..7b98647 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -2,7 +2,7 @@\n \n \n \n@@ -13,10 +13,10 @@\n@@ -28,6 +28,7 @@\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n \n+\n class RegisterFormView(BaseFormView):\n     form = RegisterForm\n \n@@ -35,10 +36,12 @@ class RegisterFormView(BaseFormView):\n         result = await self.app.services.user.register(\n             form.email.value, form.username.value, form.password.value\n         )\n-        self.request.session.update({\n-            \"uid\": result[\"uid\"],\n-            \"username\": result[\"username\"],\n-            \"logged_in\": True,\n-            \"color\": result[\"color\"]\n-        })\n-        return {\"redirect_url\": \"/web.html\"}\n\\ No newline at end of file\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 27a8dcf..19c98d4 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -7,41 +7,44 @@\n \n \n-from aiohttp import web  \n-from snek.system.view import BaseView\n-import traceback\n import json\n-from snek.system.model import now \n+import traceback\n+\n+from aiohttp import web\n+\n+from snek.system.model import now\n from snek.system.profiler import Profiler\n+from snek.system.view import BaseView\n+\n \n class RPCView(BaseView):\n \n     class RPCApi:\n         def __init__(self, view, ws):\n-            self.view = view \n+            self.view = view\n             self.app = self.view.app\n             self.services = self.app.services\n-            self.ws = ws \n+            self.ws = ws\n \n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")\n \n-        @property \n+        @property\n         def request(self):\n-            return self.view.request \n-        \n+            return self.view.request\n+\n         def _require_login(self):\n             if not self.is_logged_in:\n                 raise Exception(\"Not logged in\")\n \n-        @property \n+        @property\n         def is_logged_in(self):\n             return self.view.session.get(\"logged_in\", False)\n \n         async def mark_as_read(self, channel_uid):\n             self._require_login()\n-            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid) \n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n             return True\n \n         async def login(self, username, password):\n@@ -54,16 +57,26 @@ class RPCView(BaseView):\n             self.view.session[\"username\"] = user[\"username\"]\n             self.view.session[\"user_nick\"] = user[\"nick\"]\n             record = user.record\n-            del record['password']\n-            del record['deleted_at']\n-            await self.services.socket.add(self.ws,self.view.request.session.get('uid'))\n-            async for subscription in self.services.channel_member.find(user_uid=self.view.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(self.ws, subscription[\"channel_uid\"], self.view.request.session.get(\"uid\"))\n-            return record \n-\n-        async def search_user(self, query): \n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            await self.services.socket.add(\n+                self.ws, self.view.request.session.get(\"uid\")\n+            )\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.view.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    self.ws,\n+                    subscription[\"channel_uid\"],\n+                    self.view.request.session.get(\"uid\"),\n+                )\n+            return record\n+\n+        async def search_user(self, query):\n             self._require_login()\n-            return [user['username'] for user in await self.services.user.search(query)]\n+            return [user[\"username\"] for user in await self.services.user.search(query)]\n \n         async def get_user(self, user_uid):\n             self._require_login()\n@@ -71,46 +84,56 @@ class RPCView(BaseView):\n                 user_uid = self.user_uid\n             user = await self.services.user.get(uid=user_uid)\n             record = user.record\n-            del record['password']\n-            del record['deleted_at']\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n             if user_uid != user[\"uid\"]:\n-                del record['email']\n-            return record \n+                del record[\"email\"]\n+            return record\n \n-        async def get_messages(self, channel_uid, offset=0,timestamp = None):\n+        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n             self._require_login()\n             messages = []\n-            for message in await self.services.channel_message.offset(channel_uid, offset or 0,timestamp or None):\n-                extended_dict = await self.services.channel_message.to_extended_dict(message)\n+            for message in await self.services.channel_message.offset(\n+                channel_uid, offset or 0, timestamp or None\n+            ):\n+                extended_dict = await self.services.channel_message.to_extended_dict(\n+                    message\n+                )\n                 messages.append(extended_dict)\n             return messages\n \n         async def get_channels(self):\n             self._require_login()\n             channels = []\n-            async for subscription in self.services.channel_member.find(user_uid=self.user_uid, is_banned=False):\n-                channel = await self.services.channel.get(uid=subscription['channel_uid'])\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.user_uid, is_banned=False\n+            ):\n+                channel = await self.services.channel.get(\n+                    uid=subscription[\"channel_uid\"]\n+                )\n                 last_message = await channel.get_last_message()\n-                color = None \n+                color = None\n                 if last_message:\n                     last_message_user = await last_message.get_user()\n-                    color = last_message_user['color']\n-                channels.append({\n-                    \"name\": subscription[\"label\"],\n-                    \"uid\": subscription[\"channel_uid\"],\n-                    \"tag\": channel[\"tag\"],\n-                    \"new_count\": subscription[\"new_count\"],\n-                    \"is_moderator\": subscription[\"is_moderator\"],\n-                    \"is_read_only\": subscription[\"is_read_only\"],\n-                    'new_count': subscription['new_count'],\n-                    'color': color \n-                })\n+                    color = last_message_user[\"color\"]\n+                channels.append(\n+                    {\n+                        \"name\": subscription[\"label\"],\n+                        \"uid\": subscription[\"channel_uid\"],\n+                        \"tag\": channel[\"tag\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"is_moderator\": subscription[\"is_moderator\"],\n+                        \"is_read_only\": subscription[\"is_read_only\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"color\": color,\n+                    }\n+                )\n             return channels\n \n         async def send_message(self, channel_uid, message):\n             self._require_login()\n             await self.services.chat.send(self.user_uid, channel_uid, message)\n-            return True \n+            return True\n \n         async def echo(self, *args):\n             self._require_login()\n@@ -118,29 +141,47 @@ class RPCView(BaseView):\n \n         async def query(self, *args):\n             self._require_login()\n-            query = args[0] \n+            query = args[0]\n             lowercase = query.lower()\n-            if any(keyword in lowercase for keyword in [\"drop\", \"alter\", \"update\", \"delete\", \"replace\", \"insert\", \"truncate\"]) and 'select' not in lowercase:\n+            if (\n+                any(\n+                    keyword in lowercase\n+                    for keyword in [\n+                        \"drop\",\n+                        \"alter\",\n+                        \"update\",\n+                        \"delete\",\n+                        \"replace\",\n+                        \"insert\",\n+                        \"truncate\",\n+                    ]\n+                )\n+                and \"select\" not in lowercase\n+            ):\n                 raise Exception(\"Not allowed\")\n-            records = [dict(record) async for record in self.services.channel.query(args[0])]\n+            records = [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n             for record in records:\n                 try:\n-                    del record['email']\n+                    del record[\"email\"]\n                 except KeyError:\n-                    pass \n+                    pass\n                 try:\n                     del record[\"password\"]\n                 except KeyError:\n-                    pass \n+                    pass\n                 try:\n-                    del record['message']\n+                    del record[\"message\"]\n                 except:\n                     pass\n                 try:\n-                    del record['html']\n-                except: \n+                    del record[\"html\"]\n+                except:\n                     pass\n-            return [dict(record) async for record in self.services.channel.query(args[0])]\n+            return [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n \n         async def __call__(self, data):\n             try:\n@@ -150,30 +191,43 @@ class RPCView(BaseView):\n                     raise Exception(\"Not allowed\")\n                 args = data.get(\"args\") or []\n                 if hasattr(super(), method_name) or not hasattr(self, method_name):\n-                    return await self._send_json({\"callId\": call_id, \"data\": \"Not allowed\"})\n+                    return await self._send_json(\n+                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n+                    )\n                 method = getattr(self, method_name.replace(\".\", \"_\"), None)\n                 if not method:\n                     raise Exception(\"Method not found\")\n-                success = True \n+                success = True\n                 try:\n                     result = await method(*args)\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False\n                 if result != \"noresponse\":\n-                    await self._send_json({\"callId\": call_id, \"success\": success, \"data\": result})\n+                    await self._send_json(\n+                        {\"callId\": call_id, \"success\": success, \"data\": result}\n+                    )\n             except Exception as ex:\n                 print(str(ex), flush=True)\n-                await self._send_json({\"callId\": call_id, \"success\": False, \"data\": str(ex)})\n+                await self._send_json(\n+                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n+                )\n \n         async def _send_json(self, obj):\n             await self.ws.send_str(json.dumps(obj, default=str))\n-    \n \n         async def get_online_users(self, channel_uid):\n             self._require_login()\n-            \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_online_users(channel_uid)]\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_online_users(channel_uid)\n+            ]\n \n         async def echo(self, obj):\n             await self.ws.send_json(obj)\n@@ -182,12 +236,20 @@ class RPCView(BaseView):\n         async def get_users(self, channel_uid):\n             self._require_login()\n \n-            return [dict(uid=record['uid'],username=record['username'], nick=record['nick'],last_ping=record['last_ping']) async for record in self.services.channel.get_users(channel_uid)]\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_users(channel_uid)\n+            ]\n \n         async def ping(self, callId, *args):\n             if self.user_uid:\n                 user = await self.services.user.get(uid=self.user_uid)\n-                user['last_ping'] = now()\n+                user[\"last_ping\"] = now()\n                 await self.services.user.save(user)\n             return {\"pong\": args}\n \n@@ -196,8 +258,14 @@ class RPCView(BaseView):\n         await ws.prepare(self.request)\n         if self.request.session.get(\"logged_in\"):\n             await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n-            async for subscription in self.services.channel_member.find(user_uid=self.request.session.get(\"uid\"), deleted_at=None, is_banned=False):\n-                await self.services.socket.subscribe(ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\"))\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n+                )\n         rpc = RPCView.RPCApi(self, ws)\n         async for msg in ws:\n             if msg.type == web.WSMsgType.TEXT:\n@@ -209,7 +277,7 @@ class RPCView(BaseView):\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:\n-                pass \n+                pass\n             elif msg.type == web.WSMsgType.CLOSE:\n-                pass \n+                pass\n         return ws\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 347d3b2..d97a4b6 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -8,17 +8,17 @@\n \n@@ -28,7 +28,6 @@\n \n \n-from aiohttp import web\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n \n@@ -40,14 +39,17 @@ class SearchUserView(BaseFormView):\n         users = []\n         query = self.request.query.get(\"query\")\n         if query:\n-            users = [user.record for user in  await self.app.services.user.search(query)]\n+            users = [user.record for user in await self.app.services.user.search(query)]\n \n         if self.request.path.endswith(\".json\"):\n             return await super().get()\n         current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        return await self.render_template(\"search_user.html\", {\"users\": users, \"query\": query or '','current_user': current_user})\n+        return await self.render_template(\n+            \"search_user.html\",\n+            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n+        )\n \n     async def submit(self, form):\n         if await form.is_valid:\n-            return {\"redirect_url\": \"/search-user.html?query=\" + form['username']}\n+            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n         return {\"is_valid\": False}\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 117942a..4675572 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -25,17 +25,18 @@\n \n from snek.system.view import BaseView\n \n+\n class StatusView(BaseView):\n     async def get(self):\n         memberships = []\n         user = {}\n-        \n+\n         user_id = self.session.get(\"uid\")\n         if user_id:\n             user = await self.app.services.user.get(uid=user_id)\n             if not user:\n                 return await self.json_response({\"error\": \"User not found\"}, status=404)\n-            \n+\n             async for model in self.app.services.channel_member.find(\n                 user_uid=user_id, deleted_at=None, is_banned=False\n             ):\n@@ -69,4 +70,4 @@ class StatusView(BaseView):\n                     self.app.cache.cache, None\n                 ),\n             }\n-        )\n\\ No newline at end of file\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 6596f2c..d3af9b0 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,15 +1,17 @@\n-from snek.system.view import BaseView \n-import aiohttp \n-import asyncio\n-from snek.system.terminal import TerminalSession\n import pathlib\n \n+import aiohttp\n+\n+from snek.system.terminal import TerminalSession\n+from snek.system.view import BaseView\n+\n+\n class TerminalSocketView(BaseView):\n-    \n+\n     login_required = True\n \n     user_sessions = {}\n-    \n+\n     async def prepare_drive(self):\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n@@ -19,40 +21,34 @@ class TerminalSocketView(BaseView):\n             destination_path = root.joinpath(path.name)\n             if not path.is_dir():\n                 destination_path.write_bytes(path.read_bytes())\n-        return root \n-        \n-    async def get(self):\n-       \n+        return root\n \n+    async def get(self):\n \n         ws = aiohttp.web.WebSocketResponse()\n         await ws.prepare(self.request)\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n         root = await self.prepare_drive()\n-        \n-\n-        \n \n         command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n \n         session = self.user_sessions.get(user[\"uid\"])\n         if not session:\n             self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n-        session = self.user_sessions[user[\"uid\"]]   \n+        session = self.user_sessions[user[\"uid\"]]\n         await session.add_websocket(ws)\n \n         async for msg in ws:\n             if msg.type == aiohttp.WSMsgType.BINARY:\n                 await session.write_input(msg.data.decode())\n \n-        \n         return ws\n \n+\n class TerminalView(BaseView):\n \n     login_required = True\n \n     async def get(self):\n-        request = self.request\n-        return await self.request.app.render_template('terminal.html',self.request)\n+        return await self.request.app.render_template(\"terminal.html\", self.request)\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 4f3fe8c..bc923c6 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,5 +1,6 @@\n from snek.system.view import BaseView\n \n+\n class ThreadsView(BaseView):\n \n     async def get(self):\n@@ -12,22 +13,25 @@ class ThreadsView(BaseView):\n             if not last_message:\n                 continue\n \n-            thread[\"uid\"] = channel['uid']\n+            thread[\"uid\"] = channel[\"uid\"]\n             thread[\"name\"] = await channel_member.get_name()\n             thread[\"new_count\"] = channel_member[\"new_count\"]\n             thread[\"last_message_on\"] = channel[\"last_message_on\"]\n-            thread['created_at'] = thread['last_message_on']\n+            thread[\"created_at\"] = thread[\"last_message_on\"]\n \n-            \n             thread[\"last_message_text\"] = last_message[\"message\"]\n-            thread['last_message_user_uid'] = last_message[\"user_uid\"]\n-            user_last_message = await self.app.services.user.get(uid=last_message[\"user_uid\"])\n-            if channel['tag'] == \"dm\":\n-                thread['name_color'] = user_last_message['color']\n-            thread['last_message_user_color'] = user_last_message['color']  \n+            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(\n+                uid=last_message[\"user_uid\"]\n+            )\n+            if channel[\"tag\"] == \"dm\":\n+                thread[\"name_color\"] = user_last_message[\"color\"]\n+            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n             threads.append(thread)\n-            \n-        threads.sort(key=lambda x: x['last_message_on'] or '', reverse=True)\n \n-        return await self.render_template(\"threads.html\", dict(threads=threads,user=user))\n+        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+\n+        return await self.render_template(\n+            \"threads.html\", {\"threads\": threads, \"user\": user}\n+        )\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 8884d72..b32d94e 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,31 +1,36 @@\n \n \n \n \n-from snek.system.view import BaseView\n-import aiofiles\n import pathlib\n-from aiohttp import web\n import uuid\n \n+import aiofiles\n+from aiohttp import web\n+\n+from snek.system.view import BaseView\n+\n UPLOAD_DIR = pathlib.Path(\"./drive\")\n \n+\n class UploadView(BaseView):\n \n     async def get(self):\n         uid = self.request.match_info.get(\"uid\")\n         drive_item = await self.services.drive_item.get(uid)\n         response = web.FileResponse(drive_item[\"path\"])\n-        response.headers['Cache-Control'] = f'public, max-age={1337*420}'\n-        response.headers['Content-Disposition'] = f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        )\n         return response\n \n     async def post(self):\n@@ -36,7 +41,9 @@ class UploadView(BaseView):\n \n         channel_uid = None\n \n-        drive = await self.services.drive.get_or_create(user_uid=self.request.session.get(\"uid\"))\n+        drive = await self.services.drive.get_or_create(\n+            user_uid=self.request.session.get(\"uid\")\n+        )\n \n         extension_types = {\n             \".jpg\": \"image\",\n@@ -47,7 +54,7 @@ class UploadView(BaseView):\n             \".mp3\": \"audio\",\n             \".pdf\": \"document\",\n             \".doc\": \"document\",\n-            \".docx\": \"document\"\n+            \".docx\": \"document\",\n         }\n \n         while field := await reader.next():\n@@ -58,32 +65,45 @@ class UploadView(BaseView):\n             filename = field.filename\n             if not filename:\n                 continue\n-            \n+\n             name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n             file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n             files.append(file_path)\n \n-            async with aiofiles.open(str(file_path.absolute()), 'wb') as f:\n+            async with aiofiles.open(str(file_path.absolute()), \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n             drive_item = await self.services.drive_item.create(\n-                drive[\"uid\"], filename, str(file_path.absolute()), file_path.stat().st_size, file_path.suffix\n+                drive[\"uid\"],\n+                filename,\n+                str(file_path.absolute()),\n+                file_path.stat().st_size,\n+                file_path.suffix,\n             )\n-           \n-            type_ = \"unknown\"\n+\n             extension = \".\" + filename.split(\".\")[-1]\n             if extension in extension_types:\n-                type_ = extension_types[extension] \n-            \n+                extension_types[extension]\n+\n             await self.services.drive_item.save(drive_item)\n-            response = \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            response = \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            response = (\n+                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            )\n+            response = (\n+                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            )\n \n             await self.services.chat.send(\n                 self.request.session.get(\"uid\"), channel_uid, response\n             )\n \n-        return web.json_response({\"message\": \"Files uploaded successfully\", \"files\": [str(file) for file in files], \"channel_uid\": channel_uid})\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [str(file) for file in files],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex b9271c3..111f76c 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -25,37 +25,55 @@\n \n from aiohttp import web\n+\n from snek.system.view import BaseView\n \n+\n class WebView(BaseView):\n     login_required = True\n \n     async def get(self):\n         if self.login_required and not self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/\")\n-        channel = await self.services.channel.get(uid=self.request.match_info.get(\"channel\"))\n+        channel = await self.services.channel.get(\n+            uid=self.request.match_info.get(\"channel\")\n+        )\n         if not channel:\n-            user = await self.services.user.get(uid=self.request.match_info.get(\"channel\"))\n+            user = await self.services.user.get(\n+                uid=self.request.match_info.get(\"channel\")\n+            )\n             if user:\n-                channel = await self.services.channel.get_dm(self.session.get(\"uid\"), user[\"uid\"])\n+                channel = await self.services.channel.get_dm(\n+                    self.session.get(\"uid\"), user[\"uid\"]\n+                )\n                 if channel:\n                     return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n         if not channel:\n             return web.HTTPNotFound()\n-        \n-        channel_member = await self.app.services.channel_member.get(user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"])\n+\n+        channel_member = await self.app.services.channel_member.get(\n+            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n+        )\n         if not channel_member:\n             return web.HTTPNotFound()\n-        \n-        channel_member['new_count'] = 0\n+\n+        channel_member[\"new_count\"] = 0\n         await self.app.services.channel_member.save(channel_member)\n \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        messages = [await self.app.services.channel_message.to_extended_dict(message) for message in await self.app.services.channel_message.offset(\n-            channel[\"uid\"]\n-        )]\n+        messages = [\n+            await self.app.services.channel_message.to_extended_dict(message)\n+            for message in await self.app.services.channel_message.offset(\n+                channel[\"uid\"]\n+            )\n+        ]\n         for message in messages:\n-            await self.app.services.notification.mark_as_read(self.session.get(\"uid\"),message[\"uid\"])\n+            await self.app.services.notification.mark_as_read(\n+                self.session.get(\"uid\"), message[\"uid\"]\n+            )\n \n         name = await channel_member.get_name()\n-        return await self.render_template(\"web.html\", {\"name\": name, \"channel\": channel,\"user\": user,\"messages\": messages})\n+        return await self.render_template(\n+            \"web.html\",\n+            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n+        )\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex 27e1b4b..aab17e4 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,7 +1,9 @@\n import asyncio\n-import asyncssh\n-import os\n import logging\n+import os\n+\n+import asyncssh\n+\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n@@ -11,6 +13,7 @@ PASSWORD = \"woeii\"\n HOST = \"localhost\"\n PORT = 2225\n \n+\n class MySFTPServer(asyncssh.SFTPServer):\n     def __init__(self, chan):\n         super().__init__(chan)\n@@ -31,8 +34,10 @@ class MySFTPServer(asyncssh.SFTPServer):\n         full_path = os.path.join(self.root, path.lstrip(\"/\"))\n         return await super().listdir(full_path)\n \n+\n class MySSHServer(asyncssh.SSHServer):\n     \"\"\"Custom SSH server to handle authentication\"\"\"\n+\n     def connection_made(self, conn):\n         print(f\"New connection from {conn.get_extra_info('peername')}\")\n \n@@ -46,11 +51,12 @@ class MySSHServer(asyncssh.SSHServer):\n \n     def validate_password(self, username, password):\n-        print(username,password)\n-        \n+        print(username, password)\n+\n         return True\n         return username == USERNAME and password == PASSWORD\n \n+\n async def start_sftp_server():\n \n@@ -59,11 +65,12 @@ async def start_sftp_server():\n         host=HOST,\n         port=PORT,\n         server_host_keys=[\"ssh_host_key\"],\n-        process_factory=MySFTPServer\n+        process_factory=MySFTPServer,\n     )\n     print(f\"SFTP server running on {HOST}:{PORT}\")\n \n+\n if __name__ == \"__main__\":\n     try:\n         asyncio.run(start_sftp_server())\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 1bfec21..2fa26a7 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,7 +1,8 @@\n import asyncio\n-import asyncssh\n import os\n \n+import asyncssh\n+\n HOST = \"0.0.0.0\"\n PORT = 2225\n@@ -9,6 +10,7 @@ USERNAME = \"user\"\n PASSWORD = \"password\"\n \n+\n class CustomSSHServer(asyncssh.SSHServer):\n     def connection_made(self, conn):\n         print(f\"New connection from {conn.get_extra_info('peername')}\")\n@@ -22,6 +24,7 @@ class CustomSSHServer(asyncssh.SSHServer):\n     def validate_password(self, username, password):\n         return username == USERNAME and password == PASSWORD\n \n+\n async def custom_bash_process(process):\n     \"\"\"Spawns a custom bash shell process\"\"\"\n     env = os.environ.copy()\n@@ -29,7 +32,12 @@ async def custom_bash_process(process):\n \n     bash_proc = await asyncio.create_subprocess_exec(\n-        SHELL, \"-i\", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env\n+        SHELL,\n+        \"-i\",\n+        stdin=asyncio.subprocess.PIPE,\n+        stdout=asyncio.subprocess.PIPE,\n+        stderr=asyncio.subprocess.PIPE,\n+        env=env,\n     )\n \n     async def read_output():\n@@ -48,6 +56,7 @@ async def custom_bash_process(process):\n \n     await asyncio.gather(read_output(), read_input())\n \n+\n async def start_ssh_server():\n     \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n     await asyncssh.create_server(\n@@ -55,14 +64,14 @@ async def start_ssh_server():\n         host=HOST,\n         port=PORT,\n         server_host_keys=[\"ssh_host_key\"],\n-        process_factory=custom_bash_process\n+        process_factory=custom_bash_process,\n     )\n     print(f\"SSH server running on {HOST}:{PORT}\")\n \n+\n if __name__ == \"__main__\":\n     try:\n         asyncio.run(start_ssh_server())\n     except (OSError, asyncssh.Error) as e:\n         print(f\"Error starting SSH server: {e}\")\n-\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex d50cc54..4a09452 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -27,45 +27,48 @@\n \n-import asyncio, asyncssh, sys\n-\n-async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-\n+import asyncio\n+import sys\n \n+import asyncssh\n \n \n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n \n     width, height, pixwidth, pixheight = process.term_size\n \n-    process.stdout.write(f'Terminal type: {process.term_type}, '\n-                         f'size: {width}x{height}')\n+    process.stdout.write(\n+        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n+    )\n     if pixwidth and pixheight:\n-        process.stdout.write(f' ({pixwidth}x{pixheight} pixels)')\n-    process.stdout.write('\\nTry resizing your window!\\n')\n+        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n+    process.stdout.write(\"\\nTry resizing your window!\\n\")\n \n     while not process.stdin.at_eof():\n         try:\n             await process.stdin.read()\n         except asyncssh.TerminalSizeChanged as exc:\n-            process.stdout.write(f'New window size: {exc.width}x{exc.height}')\n+            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n             if exc.pixwidth and exc.pixheight:\n-                process.stdout.write(f' ({exc.pixwidth}'\n-                                     f'x{exc.pixheight} pixels)')\n-            process.stdout.write('\\n')\n-\n-\n+                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n+            process.stdout.write(\"\\n\")\n \n \n async def start_server() -> None:\n-    await asyncssh.listen('', 2230, server_host_keys=['ssh_host_key'],\n-                          process_factory=handle_client)\n+    await asyncssh.listen(\n+        \"\",\n+        2230,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex e54480f..187722c 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -24,32 +24,39 @@\n \n-import asyncio, asyncssh, bcrypt, sys\n+import asyncio\n+import sys\n from typing import Optional\n \n-             'user': bcrypt.hashpw(b'user', bcrypt.gensalt()),\n-            }\n+import asyncssh\n+import bcrypt\n+\n+passwords = {\n+    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n+}\n+\n \n def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-    username = process.get_extra_info('username')\n-    process.stdout.write(f'Welcome to my SSH server, {username}!\\n')\n+    username = process.get_extra_info(\"username\")\n+    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n+\n \n class MySSHServer(asyncssh.SSHServer):\n     def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n-        peername = conn.get_extra_info('peername')[0]\n-        print(f'SSH connection received from {peername}.')\n+        peername = conn.get_extra_info(\"peername\")[0]\n+        print(f\"SSH connection received from {peername}.\")\n \n     def connection_lost(self, exc: Optional[Exception]) -> None:\n         if exc:\n-            print('SSH connection error: ' + str(exc), file=sys.stderr)\n+            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n         else:\n-            print('SSH connection closed.')\n+            print(\"SSH connection closed.\")\n \n     def begin_auth(self, username: str) -> bool:\n-        return passwords.get(username) != b''\n+        return passwords.get(username) != b\"\"\n \n     def password_auth_supported(self) -> bool:\n         return True\n@@ -60,18 +67,24 @@ class MySSHServer(asyncssh.SSHServer):\n         pw = passwords[username]\n         if not password and not pw:\n             return True\n-        return bcrypt.checkpw(password.encode('utf-8'), pw)\n+        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n+\n \n async def start_server() -> None:\n-    await asyncssh.create_server(MySSHServer, '', 2231,\n-                                 server_host_keys=['ssh_host_key'],\n-                                 process_factory=handle_client)\n+    await asyncssh.create_server(\n+        MySSHServer,\n+        \"\",\n+        2231,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex bbaf3d3..cfd5d21 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -27,11 +27,15 @@\n \n-import asyncio, asyncssh, sys\n+import asyncio\n+import sys\n from typing import List, cast\n \n+import asyncssh\n+\n+\n class ChatClient:\n-    _clients: List['ChatClient'] = []\n+    _clients: List[\"ChatClient\"] = []\n \n     def __init__(self, process: asyncssh.SSHServerProcess):\n         self._process = process\n@@ -40,8 +44,6 @@ class ChatClient:\n     async def handle_client(cls, process: asyncssh.SSHServerProcess):\n         await cls(process).run()\n \n-    \n-\n     async def readline(self) -> str:\n         return cast(str, self._process.stdin.readline())\n \n@@ -53,54 +55,58 @@ class ChatClient:\n             if client != self:\n                 client.write(msg)\n \n-\n     def begin_auth(self, username: str) -> bool:\n \n     def password_auth_supported(self) -> bool:\n         return True\n \n     def validate_password(self, username: str, password: str) -> bool:\n         return True\n-\n \n     async def run(self) -> None:\n-        self.write('Welcome to chat!\\n\\n')\n+        self.write(\"Welcome to chat!\\n\\n\")\n \n-        self.write('Enter your name: ')\n-        name = (await self.readline()).rstrip('\\n')\n+        self.write(\"Enter your name: \")\n+        name = (await self.readline()).rstrip(\"\\n\")\n \n-        self.write(f'\\n{len(self._clients)} other users are connected.\\n\\n')\n+        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n \n         self._clients.append(self)\n-        self.broadcast(f'*** {name} has entered chat ***\\n')\n+        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n \n         try:\n             async for line in self._process.stdin:\n-                self.broadcast(f'{name}: {line}')\n+                self.broadcast(f\"{name}: {line}\")\n         except asyncssh.BreakReceived:\n             pass\n \n-        self.broadcast(f'*** {name} has left chat ***\\n')\n+        self.broadcast(f\"*** {name} has left chat ***\\n\")\n         self._clients.remove(self)\n \n+\n async def start_server() -> None:\n-    await asyncssh.listen('', 2235, server_host_keys=['ssh_host_key'],\n-                          process_factory=ChatClient.handle_client)\n+    await asyncssh.listen(\n+        \"\",\n+        2235,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=ChatClient.handle_client,\n+    )\n+\n \n loop = asyncio.new_event_loop()\n \n try:\n     loop.run_until_complete(start_server())\n except (OSError, asyncssh.Error) as exc:\n-    sys.exit('Error starting server: ' + str(exc))\n+    sys.exit(\"Error starting server: \" + str(exc))\n \n loop.run_forever()"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Initial implementation of WebDAV functionality with basic operations", "commit": "9a923f6bddd73df27af80ef6c8e2313816a07a48", "diff": "commit 9a923f6bddd73df27af80ef6c8e2313816a07a48\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:15:53 2025 +0100\n\n    WEebdav.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nnew file mode 100644\nindex 0000000..b28b04f\n--- /dev/null\n+++ b/src/snek/__init__.py\n@@ -0,0 +1,3 @@\n+\n+\n+\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nnew file mode 100644\nindex 0000000..30f0209\n--- /dev/null\n+++ b/src/snek/__main__.py\n@@ -0,0 +1,5 @@\n+from aiohttp import web \n+from snek.app import Application\n+\n+if __name__ == '__main__':\n+    web.run_app(Application(), port=8081,host='0.0.0.0')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nnew file mode 100755\nindex 0000000..66aeb3d\n--- /dev/null\n+++ b/src/snek/webdav.py\n@@ -0,0 +1,348 @@\n+import logging\n+\n+import pathlib\n+logging.basicConfig(level=logging.DEBUG)\n+\n+import asyncio\n+import base64\n+import datetime\n+import mimetypes\n+import os\n+import shutil\n+import uuid\n+\n+import aiofiles\n+import aiohttp\n+import aiohttp.web\n+from lxml import etree\n+\n+\n+class WebdavApplication(aiohttp.web.Application):\n+    def __init__(self, parent, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.locks = {}\n+        \n+        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n+        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n+        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n+        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n+        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n+        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n+        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n+        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n+        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n+        self.parent = parent\n+\n+    @property \n+    def db(self):\n+        return self.parent.db\n+\n+    @property \n+    def services(self):\n+        return self.parent.services \n+\n+\n+    async def authenticate(self, request):\n+        session = request.session\n+        if session.get('uid'):\n+            request['user'] = await self.services.user.get(uid=session['uid'])\n+            try:\n+                request['home'] = await self.services.user.get_home_folder(user_uid=request['user']['uid'])\n+            except:\n+                pass\n+            return user\n+\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return False\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request['user'] = await self.services.user.authenticate(username=username, password=password)\n+        try:\n+            request['home'] = await self.services.user.get_home_folder(request['user']['uid'])\n+        except Exception as ex:\n+            print(ex)\n+            pass\n+        return request['user']\n+\n+    async def handle_get(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request['home'] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+\n+        return aiohttp.web.FileResponse(\n+            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n+        )\n+\n+    async def handle_put(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path.parent.mkdir(parents=True, exist_ok=True)\n+        async with aiofiles.open(file_path, \"wb\") as f:\n+            while chunk := await request.content.read(1024):\n+                await f.write(chunk)\n+        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n+\n+    async def handle_delete(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request['home'] / request.match_info[\"filename\"]\n+        if file_path.is_file():\n+            file_path.unlink()\n+            return aiohttp.web.Response(status=204)\n+        elif file_path.is_dir():\n+            shutil.rmtree(file_path)\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=404, text=\"Not found\")\n+\n+    async def handle_mkcol(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        dir_path = request['home'] / request.match_info[\"filename\"]\n+        if dir_path.exists():\n+            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n+        dir_path.mkdir(parents=True, exist_ok=True)\n+        return aiohttp.web.Response(status=201, text=\"Directory created\")\n+\n+    async def handle_move(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request['home'] / request.match_info[\"filename\"]\n+        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        shutil.move(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n+\n+    async def handle_copy(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request['home'] / request.match_info[\"filename\"]\n+        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        if src_path.is_file():\n+            shutil.copy2(str(src_path), str(dest_path))\n+        else:\n+            shutil.copytree(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n+\n+    async def handle_options(self, request):\n+        headers = {\n+            \"DAV\": \"1, 2\",\n+            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n+        }\n+        print(\"RETURN\")\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+    def get_current_utc_time(self, filepath):\n+        if filepath.exists():\n+            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n+        else:\n+            modified_time = datetime.datetime.utcnow()\n+        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n+            \"%a, %d %b %Y %H:%M:%S GMT\"\n+        )\n+\n+    def get_directory_size(self, directory):\n+        total_size = 0\n+        for dirpath, _, filenames in os.walk(directory):\n+            for f in filenames:\n+                fp = pathlib.Path(dirpath) / f\n+                if fp.exists():\n+                    total_size += fp.stat().st_size\n+        return total_size\n+\n+    def get_disk_free_space(self, path):\n+        statvfs = os.statvfs(path)\n+        return statvfs.f_bavail * statvfs.f_frsize\n+\n+    async def handle_propfind(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        requested_path = request.match_info.get(\"filename\", \"\") \n+        abs_path = request['home'] / requested_path\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n+        nsmap = {\"D\": \"DAV:\"}\n+        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n+        directories = [requested_path]\n+        if abs_path.is_dir():\n+            directories.extend(os.listdir(abs_path))\n+        for item in directories:\n+            full_path = abs_path / item if item != requested_path else abs_path\n+            href_path = f\"/{requested_path}/{item}/\" if item != requested_path else f\"/{requested_path}/\"\n+            response = etree.SubElement(response_xml, \"{DAV:}response\")\n+            href = etree.SubElement(response, \"{DAV:}href\")\n+            if not full_path.is_dir():\n+                href_path = href_path.rstrip(\"/\")\n+            href.text = href_path\n+            propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+            prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+            res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+            if full_path.is_dir():\n+                etree.SubElement(res_type, \"{DAV:}collection\")\n+            creation_date, last_modified = self.get_current_utc_time(full_path)\n+            etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+            etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n+            etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+                self.get_disk_free_space(request['home'])\n+            )\n+            etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+            etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+            etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+            mimetype, _ = mimetypes.guess_type(full_path.name)\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n+            supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+            lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+            lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+            etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+            lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+            etree.SubElement(lock_type_1, \"{DAV:}write\")\n+            lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+            lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+            etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+            lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+            etree.SubElement(lock_type_2, \"{DAV:}write\")\n+            etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        xml_output = etree.tostring(\n+            response_xml, encoding=\"utf-8\", xml_declaration=True\n+        ).decode()\n+        return aiohttp.web.Response(\n+            status=207, text=xml_output, content_type=\"application/xml\"\n+        )\n+\n+    async def handle_proppatch(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n+\n+    async def handle_lock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_id = str(uuid.uuid4())\n+        self.locks[resource] = lock_id\n+        xml_response = self.generate_lock_response(lock_id)\n+        headers = {\n+            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n+            \"Content-Type\": \"application/xml\",\n+        }\n+        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n+\n+    async def handle_unlock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n+            \"opaquelocktoken:\", \"\"\n+        )\n+        if self.locks.get(resource) == lock_token:\n+            del self.locks[resource]\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n+\n+    def generate_lock_response(self, lock_id):\n+        nsmap = {\"D\": \"DAV:\"}\n+        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n+        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n+        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n+        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type, \"{DAV:}write\")\n+        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n+        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n+        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n+        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n+        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n+        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n+        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n+        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n+\n+    def get_last_modified(self, path):\n+        if not path.exists():\n+            return None\n+        timestamp = path.stat().st_mtime\n+        dt = datetime.datetime.utcfromtimestamp(timestamp)\n+        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n+\n+    async def handle_head(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        print(requested_path) \n+        abs_path = request['home'] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(\n+                status=403, text=\"Cannot get metadata for a directory\"\n+            )\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+        file_size = abs_path.stat().st_size\n+\n+        headers = {\n+            \"Content-Type\": content_type,\n+            \"Content-Length\": str(file_size),\n+            \"Last-Modified\": self.get_last_modified(abs_path),\n+        }\n+\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented basic WebDAV authentication", "commit": "886d21999c716ee306318796f3f159ba085f9618", "diff": "commit 886d21999c716ee306318796f3f159ba085f9618\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:19:08 2025 +0100\n\n    Added webdav.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 66aeb3d..3033e3a 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -45,14 +45,14 @@ class WebdavApplication(aiohttp.web.Application):\n \n \n     async def authenticate(self, request):\n-        session = request.session\n-        if session.get('uid'):\n-            request['user'] = await self.services.user.get(uid=session['uid'])\n-            try:\n-                request['home'] = await self.services.user.get_home_folder(user_uid=request['user']['uid'])\n-            except:\n-                pass\n-            return user\n \n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Use `/home` instead of `/drive` for user home folders", "commit": "6dca3a96e1cd23cc26d69aeee31ae45e14977bbd", "diff": "commit 6dca3a96e1cd23cc26d69aeee31ae45e14977bbd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 07:27:56 2025 +0100\n\n    Added webdav.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6055403..70e8adf 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -40,7 +40,7 @@ class UserService(BaseService):\n         return model\n \n     async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        folder = pathlib.Path(f\"./home/{user_uid}\")\n         if not folder.exists():\n             folder.mkdir(parents=True, exist_ok=True)\n         return folder"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented recursive node creation for propfind requests", "commit": "3926b2d837bef181546d826b41821cf90fc755a8", "diff": "commit 3926b2d837bef181546d826b41821cf90fc755a8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat Mar 29 10:50:40 2025 +0100\n\n    Update storage.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 3033e3a..e03e675 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -188,11 +188,74 @@ class WebdavApplication(aiohttp.web.Application):\n         statvfs = os.statvfs(path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n+\n+    async def create_node(self, request, response_xml, full_path, depth):\n+        requested_path = request.match_info.get(\"filename\", \"\") \n+        abs_path = pathlib.Path(full_path)\n+        relative_path = str(full_path.relative_to(request['home']))\n+\n+        href_path = f\"{relative_path}\".strip(\"/\")\n+        response = etree.SubElement(response_xml, \"{DAV:}response\")\n+        href = etree.SubElement(response, \"{DAV:}href\")\n+        href.text = href_path\n+        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+        if full_path.is_dir():\n+            etree.SubElement(res_type, \"{DAV:}collection\")\n+        creation_date, last_modified = self.get_current_utc_time(full_path)\n+        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+            full_path.stat().st_size\n+            if full_path.is_file()\n+            else self.get_directory_size(full_path)\n+        )\n+        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+            self.get_disk_free_space(request['home'])\n+        )\n+        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+        mimetype, _ = mimetypes.guess_type(full_path.name)\n+        etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+        etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+            full_path.stat().st_size\n+            if full_path.is_file()\n+            else self.get_directory_size(full_path)\n+        )\n+        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_1, \"{DAV:}write\")\n+        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_2, \"{DAV:}write\")\n+        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        \n+        if abs_path.is_dir() and depth != -1:\n+            for item in abs_path.iterdir():\n+                await self.create_node(request,response_xml, item, depth - 1)\n+\n+\n+\n+\n     async def handle_propfind(self, request):\n         if not await self.authenticate(request):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n+\n+        depth = 0\n+        try:\n+            depth = int(request.headers.get(\"Depth\", \"0\"))\n+        except ValueError:\n+            pass\n         requested_path = request.match_info.get(\"filename\", \"\") \n         abs_path = request['home'] / requested_path\n         if not abs_path.exists():\n@@ -200,54 +263,9 @@ class WebdavApplication(aiohttp.web.Application):\n         nsmap = {\"D\": \"DAV:\"}\n         response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n         directories = [requested_path]\n-        if abs_path.is_dir():\n-            directories.extend(os.listdir(abs_path))\n-        for item in directories:\n-            full_path = abs_path / item if item != requested_path else abs_path\n-            href_path = f\"/{requested_path}/{item}/\" if item != requested_path else f\"/{requested_path}/\"\n-            response = etree.SubElement(response_xml, \"{DAV:}response\")\n-            href = etree.SubElement(response, \"{DAV:}href\")\n-            if not full_path.is_dir():\n-                href_path = href_path.rstrip(\"/\")\n-            href.text = href_path\n-            propstat = etree.SubElement(response, \"{DAV:}propstat\")\n-            prop = etree.SubElement(propstat, \"{DAV:}prop\")\n-            res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n-            if full_path.is_dir():\n-                etree.SubElement(res_type, \"{DAV:}collection\")\n-            creation_date, last_modified = self.get_current_utc_time(full_path)\n-            etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n-            etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-                full_path.stat().st_size\n-                if full_path.is_file()\n-                else self.get_directory_size(full_path)\n-            )\n-            etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-                self.get_disk_free_space(request['home'])\n-            )\n-            etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n-            etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n-            etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n-            mimetype, _ = mimetypes.guess_type(full_path.name)\n-            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                full_path.stat().st_size\n-                if full_path.is_file()\n-                else self.get_directory_size(full_path)\n-            )\n-            supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n-            lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-            lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n-            etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n-            lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n-            etree.SubElement(lock_type_1, \"{DAV:}write\")\n-            lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-            lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n-            etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n-            lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n-            etree.SubElement(lock_type_2, \"{DAV:}write\")\n-            etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+        \n+        await self.create_node(request, response_xml, abs_path, depth)\n+\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Updated home folder path to drive and removed lock implementation", "commit": "d5917b94540aee206935354f438a6a7f893278ec", "diff": "commit d5917b94540aee206935354f438a6a7f893278ec\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 06:54:25 2025 +0200\n\n    New version.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 70e8adf..6055403 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -40,7 +40,7 @@ class UserService(BaseService):\n         return model\n \n     async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./home/{user_uid}\")\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\n             folder.mkdir(parents=True, exist_ok=True)\n         return folder\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex e03e675..b823c19 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -287,7 +287,7 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n-        self.locks[resource] = lock_id\n         xml_response = self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\","}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Remove unused LOCK and UNLOCK routes", "commit": "8058e4a4b0aee254edc76fa28b2c4336eb393c4b", "diff": "commit 8058e4a4b0aee254edc76fa28b2c4336eb393c4b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 07:02:35 2025 +0200\n\n    New version.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex b823c19..a372b8b 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -31,8 +31,8 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n-        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n-        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n         self.parent = parent\n \n     @property"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Handle potential errors when creating home folders", "commit": "2a47c0ba5e7344d44c3acd15d8f3efeb11722677", "diff": "commit 2a47c0ba5e7344d44c3acd15d8f3efeb11722677\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Mar 30 09:34:13 2025 +0200\n\n    Fixed bug.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6055403..b70be63 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,7 +42,10 @@ class UserService(BaseService):\n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\n-            folder.mkdir(parents=True, exist_ok=True)\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n         return folder\n \n     async def register(self, email, username, password):"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added settings page with profile, notifications, and privacy sections", "commit": "32e0c959e8589f574d1c46b96d35f49c98721566", "diff": "commit 32e0c959e8589f574d1c46b96d35f49c98721566\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 16:21:29 2025 +0200\n\n    Progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 984fcf3..d043c0e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -42,6 +42,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n+from snek.view.settings import SettingsView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -137,6 +138,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\n+        self.router.add_view(\"/settings.html\", SettingsView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginView)\ndiff --git a/src/snek/templates/settings.html b/src/snek/templates/settings.html\nnew file mode 100644\nindex 0000000..cfb186c\n--- /dev/null\n+++ b/src/snek/templates/settings.html\n@@ -0,0 +1,36 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+{% include \"sidebar_settings.html\" %}\n+\n+{% endblock %}\n+\n+{% block head %}\n+\n+\n+{% endblock %}\n+\n+{% block main %}\n+\n+<h1>Setting page</h1>\n+\n+<div id=\"profile_description\"></div>\n+\n+\n+\n+<script type=\"module\">\n+\n+\n+        require(['vs/editor/editor.main'], function () {\n+var editor = monaco.editor.create(document.getElementById('profile_description'), {\n+                value: phpCode,\n+                language: 'php'\n+            });\n+        })\n+</script>\n+\n+{% endblock main %}\ndiff --git a/src/snek/templates/sidebar_settings.html b/src/snek/templates/sidebar_settings.html\nnew file mode 100644\nindex 0000000..8e18412\n--- /dev/null\n+++ b/src/snek/templates/sidebar_settings.html\n@@ -0,0 +1,14 @@\n+<style>\n+  .channel-list-item-highlight {\n+  }\n+</style>\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>Settings</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/settings.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/settings-notifications.html\">Notifications</a></li>\n+        <li><a class=\"no-select\" href=\"/settings-privacy.html\">Privacy</a></li> \n+    </ul>\n+\n+  </aside>\ndiff --git a/src/snek/view/settings.py b/src/snek/view/settings.py\nnew file mode 100644\nindex 0000000..fe181f2\n--- /dev/null\n+++ b/src/snek/view/settings.py\n@@ -0,0 +1,8 @@\n+from snek.system.view import BaseView \n+\n+class SettingsView(BaseView):\n+    \n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template('settings.html')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex a372b8b..9a2b9e4 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -219,8 +219,9 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n         etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n         mimetype, _ = mimetypes.guess_type(full_path.name)\n-        etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-        etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+        if full_path.is_file():\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n             full_path.stat().st_size\n             if full_path.is_file()\n             else self.get_directory_size(full_path)"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Improve responsiveness for smaller screens", "commit": "87b48af551d2ed023f77ca57d033ed0079d303f3", "diff": "commit 87b48af551d2ed023f77ca57d033ed0079d303f3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:08:01 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 31a6e74..db9dcb0 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -347,3 +347,15 @@ a {\n }\n \n+@media only screen and (max-width: 600px) {\n+    header{\n+        position: sticky;\n+        display: block;\n+        .logo {\n+            display:block;\n+        }\n+    }\n+    .chat-input {\n+        position:sticky;\n+    }\n+}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 28ad4d2..d386b67 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -12,10 +12,10 @@\n         {% endfor %}\n     </div>\n     <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n-    <div class=\"chat-input\">\n+    <footer class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </div>\n+    </footer>\n </section>\n \n <script type=\"module\">"}
{"repo": ".", "date": "2025-04-01", "line": "refactor: Reduced message list height", "commit": "18b1ec20b67522cf816b29f3cde64a935ec5b330", "diff": "commit 18b1ec20b67522cf816b29f3cde64a935ec5b330\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:20:18 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex db9dcb0..da74695 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -101,7 +101,7 @@ a {\n \n .message-list {\n   flex: 1;\n-  height: 200px;\n+  height: 10px;\n   padding-bottom: 40px;\n   overflow-y: auto;\n }\n@@ -112,7 +112,7 @@ a {\n   scrollbar-width: none;\n   -ms-overflow-style: none;\n   padding: 10px;\n-  height: 200px;\n+  height: 10px;\n }"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Removed chat window element", "commit": "7c52c2d9d5f10623ccf479918ea5baed247a07b5", "diff": "commit 7c52c2d9d5f10623ccf479918ea5baed247a07b5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:23:35 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d386b67..3c7bbf9 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -11,7 +11,6 @@\n             {% endautoescape %}\n         {% endfor %}\n     </div>\n-    <chat-window style=\"display:none\" class=\"chat-area\"></chat-window>\n     <footer class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added footer styling for improved layout", "commit": "fbd4fa4e668628c11d8b592718cfcdcca71a3c0f", "diff": "commit fbd4fa4e668628c11d8b592718cfcdcca71a3c0f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 20:28:56 2025 +0200\n\n    Update css.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex da74695..7a4617d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -99,6 +99,13 @@ a {\n \n }\n \n+footer {\n+    display: flex;\n+    justify-content: space-between;\n+    align-items: center;\n+    padding: 10px 20px;\n+}\n+\n .message-list {\n   flex: 1;\n   height: 10px;"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Style adjustments for mobile view", "commit": "d24627b35fd2201e6baad781a11fbae0c379f366", "diff": "commit d24627b35fd2201e6baad781a11fbae0c379f366\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:06:07 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex 319d7d3..be4b327 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -266,7 +266,6 @@ class GenericForm extends HTMLElement {\n     }\n \n     div {\n         border-radius: 10px;\n         padding: 30px;\n         width: 400px;\n@@ -429,4 +428,4 @@ class GenericForm extends HTMLElement {\n   }\n }\n \n-customElements.define('generic-form', GenericForm);\n\\ No newline at end of file\n+customElements.define('generic-form', GenericForm);\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 0661225..f10f780 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -4,13 +4,16 @@\n   \n     box-sizing: border-box;\n   }\n+    \n+  body {\n+  }\n \n   .dialog {\n \n     border-radius: 10px;\n     padding: 30px;\n-    width: 800px;\n+    width: 600px;\n     margin: 30px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n }\n@@ -40,7 +43,6 @@ h2 {\n }\n body {\n     font-family: Arial, sans-serif;\n     line-height: 1.5;\n     display: flex;\n@@ -53,4 +55,4 @@ body {\n div {\n     text-align: left;\n \n-}\n\\ No newline at end of file\n+}\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex ffbd5bc..a1e8894 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -5,7 +5,14 @@\n   <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n   <title>Snek chat by Molodetz</title>\n     <link rel=\"stylesheet\" href=\"generic-form.css\">\n-  <link rel=\"stylesheet\" href=\"register__.css\">\n+    <link rel=\"stylesheet\" href=\"base.css\">\n+<style>\n+  .registration-container {\n+        max-width: 300px;\n+        margin: 20px auto;\n+        padding: 20px;\n+  }\n+</style>\n     <script src=\"/fancy-button.js\"></script>\n </head>\n@@ -14,9 +21,11 @@\n     <h1>Snek</h1>\n     <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n     So Snek came through, lean and optimized.</p>\n+    <div style=\"text-align: center;\">\n     <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n     <span style=\"padding:10px;\">OR</span>\n     <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n+    </div>\n   </div>\n </body>\n </html>\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex be79328..fe8cf4d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -15,6 +15,8 @@ from snek.system.view import BaseFormView\n class LoginView(BaseFormView):\n     form = LoginForm\n \n+    login_required = False\n+\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 7fbce9d..96eed8a 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -15,6 +15,8 @@ from snek.system.view import BaseFormView\n class RegisterView(BaseFormView):\n     form = RegisterForm\n \n+    login_required = False\n+\n     async def get(self):\n         if self.session.get(\"logged_in\"):\n             return web.HTTPFound(\"/web.html\")"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Reduced container width for mobile responsiveness", "commit": "e3c997302b07228c0791d6307b429109b6eb3d53", "diff": "commit e3c997302b07228c0791d6307b429109b6eb3d53\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:09:58 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex f10f780..6e81345 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -13,7 +13,7 @@\n \n     border-radius: 10px;\n     padding: 30px;\n-    width: 600px;\n+    width: 500px;\n     margin: 30px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n }"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted form width for mobile responsiveness", "commit": "27c0abea3147e5e00a5196fa9b351141a9d17ae2", "diff": "commit 27c0abea3147e5e00a5196fa9b351141a9d17ae2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 1 21:23:10 2025 +0200\n\n    Upated for phone.\n\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nindex d593816..7010193 100644\n--- a/src/snek/static/generic-form.css\n+++ b/src/snek/static/generic-form.css\n@@ -14,6 +14,7 @@\n     justify-content: center;\n     align-items: center;\n     height: 100vh;\n+    width: 100vw;\n   }\n \n generic-form {\n@@ -29,7 +30,6 @@ generic-form {\n     border-radius: 10px;\n     padding: 30px;\n-    width: 400px;\n     box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n     text-align: center;\n \n@@ -93,8 +93,7 @@ input {\n }\n \n \n-@media (max-width: 500px) {\n+@media (max-width: 600px) {\n     .generic-form-container {\n-        width: 90%;\n     }\n-}\n\\ No newline at end of file\n+}"}
{"repo": ".", "date": "2025-04-02", "line": "fix: Handle missing last message gracefully", "commit": "b365afc910522a484ad257af6df7b607712c71f2", "diff": "commit b365afc910522a484ad257af6df7b607712c71f2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 10:52:02 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 183ddb0..0a90c39 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -13,12 +13,15 @@ class ChannelModel(BaseModel):\n     last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n \n     async def get_last_message(self) -> ChannelMessageModel:\n-        async for model in self.app.services.channel_message.query(\n-            \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n-            {\"channel_uid\": self[\"uid\"]},\n-        ):\n+        try:\n+            async for model in self.app.services.channel_message.query(\n+                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+                {\"channel_uid\": self[\"uid\"]},\n+            ):\n \n-            return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+        except:\n+            pass\n         return None\n \n     async def get_members(self):"}
{"repo": ".", "date": "2025-04-02", "line": "feat: Added unique session ID and updated styling for mobile responsiveness", "commit": "99b2beeab0d55242537b7ec810bfdd69feb47103", "diff": "commit 99b2beeab0d55242537b7ec810bfdd69feb47103\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 14:55:18 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex d043c0e..c6c2e2f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -2,7 +2,7 @@ import asyncio\n import logging\n import pathlib\n import time\n-\n+import uuid\n from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n@@ -189,6 +189,8 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n+        \n+        context['rid'] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n                 user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7a4617d..049241d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -42,6 +42,7 @@ header {\n   padding-top: 10px;\n   padding-left: 20px;\n   padding-right: 20px;\n+    padding-bottom: 10px;\n   display: flex;\n   justify-content: space-between;\n   align-items: center;\n@@ -79,6 +80,17 @@ a {\n   margin-bottom: 3px;\n }\n \n+h1 {\n+    font-size: 2em;\n+}\n+\n+h2 {\n+    font-size: 1.4em;\n+}\n+\n+\n .chat-area {\n   flex: 1;\n   display: flex;\n@@ -354,7 +366,31 @@ a {\n }\n \n-@media only screen and (max-width: 600px) {\n+@media only screen and (max-width: 768px) {\n+    \n+    header{\n+        position:fixed;\n+        top: 0;\n+        left: 0;\n+            text-overflow: ellipsis;\n+\n+        *{\n+            font-size: 12px !important;\n+        }\n+        .logo {\n+            text-overflow: ellipsis;\n+            white-space: nowrap;\n+            overflow: hidden;\n+            h2 {\n+                font-size: 12px;\n+            }\n+        }\n+        \n+    }\n+    body {\n+        justify-content: flex-start;\n+    }\n     header{\n         position: sticky;\n         display: block;\n@@ -364,5 +400,5 @@ a {\n     }\n     .chat-input {\n         position:sticky;\n-    }\n }\ndiff --git a/src/snek/static/generic-form.css b/src/snek/static/generic-form.css\nindex 7010193..025bfb3 100644\n--- a/src/snek/static/generic-form.css\n+++ b/src/snek/static/generic-form.css\n@@ -34,11 +34,11 @@ generic-form {\n     text-align: center;\n \n }\n-\n .generic-form-container h1 {\n     font-size: 2em;\n     margin-bottom: 20px;\n+\n }\n input {\n \ndiff --git a/src/snek/static/generic-form.js b/src/snek/static/generic-form.js\nindex be4b327..f2edacb 100644\n--- a/src/snek/static/generic-form.js\n+++ b/src/snek/static/generic-form.js\n@@ -266,9 +266,9 @@ class GenericForm extends HTMLElement {\n     }\n \n     div {\n+        min-width:350px;\n         border-radius: 10px;\n         padding: 30px;\n-        width: 400px;\n         box-shadow: 0 0 15px rgba(0, 0, 0, 0.5);\n         text-align: center;\n     }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex 6e81345..af04db4 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -56,3 +56,9 @@ div {\n     text-align: left;\n \n }\n+\n+@media screen and (max-width: 500px) {\n+    body {\n+\n+    justify-content: flex-start;\n+    }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 1533153..6c3f137 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -21,11 +21,12 @@\n </head>\n <body>\n+\n+\n+\n   <header>\n-    <div class=\"no-select logo\" style=\"display:none\">Snek</div>\n-    \n-    <div class=\"logo\">{% block header_text %}{% endblock %}</div>\n-    <nav class=\"no-select\">\n+    <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n+    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n@@ -39,6 +40,7 @@\n     {% block sidebar %}\n     {% include \"sidebar_channels.html\" %}\n     {% endblock %}\n+    \n     {% block main %}\n     <chat-window class=\"chat-area\"></chat-window>\n     {% endblock %}\ndiff --git a/src/snek/templates/base.html b/src/snek/templates/base.html\nindex d2f8de7..2d2620e 100644\n--- a/src/snek/templates/base.html\n+++ b/src/snek/templates/base.html\n@@ -16,10 +16,10 @@\n     <script src=\"/app.js\" type=\"module\"></script>\n     <script src=\"/message-list.js\" type=\"module\"></script>\n     <style>{{ highlight_styles }}</style>\n-    <link rel=\"stylesheet\" href=\"/style.css\">\n+    <link rel=\"stylesheet\" href=\"/style.css?rid={{ rid }}\">\n     <script src=\"/fancy-button.js\" type=\"module\"></script>\n     <script src=\"/html-frame.js\" type=\"module\"></script>\n-    <script src=\"/generic-form.js\" type=\"module\"></script>\n+    <script src=\"/generic-form.js?rid={{ rid }}\" type=\"module\"></script>\n     <link rel=\"stylesheet\" href=\"/html-frame.css\">\n     {% block head %}\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex 66371f1..bf3cab1 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,10 +4,12 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n     <h2 class=\"no-select\">Terminals</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n     </ul>\n     {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3c7bbf9..fa5e03e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -1,9 +1,14 @@\n {% extends \"app.html\" %}\n \n-{% block header_text %}<h2>{{ name }}</h2>{% endblock %} \n \n {% block main %}\n-<section class=\"chat-area\" id=\"chat\">\n+\n+\n+\n+\n+\n+<section class=\"chat-area\">\n     <div class=\"chat-messages\">\n         {% for message in messages %}\n             {% autoescape false %}\n@@ -11,10 +16,10 @@\n             {% endautoescape %}\n         {% endfor %}\n     </div>\n-    <footer class=\"chat-input\">\n+    <div class=\"chat-input\">\n         <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </footer>\n+    </div>\n </section>\n \n <script type=\"module\">\n@@ -137,6 +142,8 @@\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) { \n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+             const inputBox = document.querySelector(\".chat-input\");\n+             inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }"}
{"repo": ".", "date": "2025-04-02", "line": "style: Adjusted layout and overflow behavior for improved responsiveness.", "commit": "81479e7058feda9954fd74810d1294fb92e7a1c4", "diff": "commit 81479e7058feda9954fd74810d1294fb92e7a1c4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 2 15:02:55 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 049241d..7384ad7 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -373,7 +373,7 @@ a {\n         top: 0;\n         left: 0;\n             text-overflow: ellipsis;\n-\n+        width:100%;\n         *{\n             font-size: 12px !important;\n         }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 6c3f137..5ab68c4 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -26,7 +26,7 @@\n \n   <header>\n     <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n-    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n+    <nav class=\"no-select\" style=\"overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Refactor settings view and sidebar", "commit": "d10768403d221fbd1d50a520c083b8d1be1b3a19", "diff": "commit d10768403d221fbd1d50a520c083b8d1be1b3a19\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 3 08:34:25 2025 +0200\n\n    Progress.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex b28b04f..8b13789 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -1,3 +1 @@\n \n-\n-\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 30f0209..692ad68 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,5 +1,6 @@\n-from aiohttp import web \n+from aiohttp import web\n+\n from snek.app import Application\n \n-if __name__ == '__main__':\n-    web.run_app(Application(), port=8081,host='0.0.0.0')\n+if __name__ == \"__main__\":\n+    web.run_app(Application(), port=8081, host=\"0.0.0.0\")\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c6c2e2f..25913b9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,6 +3,7 @@ import logging\n import pathlib\n import time\n import uuid\n+\n from snek.view.threads import ThreadsView\n \n logging.basicConfig(level=logging.DEBUG)\n@@ -24,7 +25,7 @@ from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import cors_middleware\n+from snek.system.middleware import auth_middleware, cors_middleware\n from snek.system.profiler import profiler_handler\n from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n from snek.view.about import AboutHTMLView, AboutMDView\n@@ -37,12 +38,13 @@ from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n+from snek.view.settings.index import SettingsIndexView\n+from snek.view.settings.profile import SettingsProfileView\n from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n-from snek.view.settings import SettingsView\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -76,6 +78,7 @@ class Application(BaseApplication):\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self.tasks = asyncio.Queue()\n         self._middlewares.append(session_middleware)\n+        self._middlewares.append(auth_middleware)\n         self.jinja2_env.add_extension(MarkdownExtension)\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n@@ -138,7 +141,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/docs.html\", DocsHTMLView)\n         self.router.add_view(\"/docs.md\", DocsMDView)\n         self.router.add_view(\"/status.json\", StatusView)\n-        self.router.add_view(\"/settings.html\", SettingsView)\n+        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n+        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n+        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n         self.router.add_view(\"/web.html\", WebView)\n         self.router.add_view(\"/login.html\", LoginView)\n         self.router.add_view(\"/login.json\", LoginView)\n@@ -189,8 +194,8 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-        \n-        context['rid'] = str(uuid.uuid4())\n+\n+        context[\"rid\"] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n                 user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 96053ea..2d4b12c 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -7,6 +7,7 @@ from snek.mapper.drive import DriveMapper\n from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n+from snek.mapper.user_property import UserPropertyMapper\n from snek.system.object import Object\n \n \n@@ -21,6 +22,7 @@ def get_mappers(app=None):\n             \"notification\": NotificationMapper(app=app),\n             \"drive_item\": DriveItemMapper(app=app),\n             \"drive\": DriveMapper(app=app),\n+            \"user_property\": UserPropertyMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex c87d39c..a1009a5 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -5,7 +5,11 @@ from snek.model.channel_member import ChannelMemberModel\n \n from snek.model.channel_message import ChannelMessageModel\n+from snek.model.drive import DriveModel\n+from snek.model.drive_item import DriveItemModel\n+from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n+from snek.model.user_property import UserPropertyModel\n from snek.system.object import Object\n \n \n@@ -17,6 +21,10 @@ def get_models():\n             \"channel_member\": ChannelMemberModel,\n             \"channel\": ChannelModel,\n             \"channel_message\": ChannelMessageModel,\n+            \"drive_item\": DriveItemModel,\n+            \"drive\": DriveModel,\n+            \"notification\": NotificationModel,\n+            \"user_property\": UserPropertyModel,\n         }\n     )\n \ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 1a6c7e6..3a9a055 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -28,6 +28,16 @@ async def cors_allow_middleware(request, handler):\n     return response\n \n \n+@web.middleware\n+async def auth_middleware(request, handler):\n+    request[\"user\"] = None\n+    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n+        request[\"user\"] = await request.app.services.user.get(\n+            uid=request.app.session.get(\"uid\")\n+        )\n+    return await handler(request)\n+\n+\n @web.middleware\n async def cors_middleware(request, handler):\n     if request.headers.get(\"Allow\"):\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 5ab68c4..b05eb21 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -31,7 +31,7 @@\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/settings/index.html\">\u2699\ufe0f</a>\n       <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n     </nav>\n \ndiff --git a/src/snek/templates/settings.html b/src/snek/templates/settings.html\nindex cfb186c..1ceb629 100644\n--- a/src/snek/templates/settings.html\n+++ b/src/snek/templates/settings.html\n@@ -13,24 +13,14 @@\n \n {% endblock %}\n \n-{% block main %}\n-\n+{% block logo %}\n <h1>Setting page</h1>\n \n-<div id=\"profile_description\"></div>\n-\n+{% endblock %}\n \n+{% block main %}\n \n-<script type=\"module\">\n \n \n-        require(['vs/editor/editor.main'], function () {\n-var editor = monaco.editor.create(document.getElementById('profile_description'), {\n-                value: phpCode,\n-                language: 'php'\n-            });\n-        })\n-</script>\n \n {% endblock main %}\ndiff --git a/src/snek/templates/sidebar_settings.html b/src/snek/templates/sidebar_settings.html\ndeleted file mode 100644\nindex 8e18412..0000000\n--- a/src/snek/templates/sidebar_settings.html\n+++ /dev/null\n@@ -1,14 +0,0 @@\n-<style>\n-  .channel-list-item-highlight {\n-  }\n-</style>\n-<aside class=\"sidebar\" id=\"channelSidebar\">\n-    <h2>Settings</h2>\n-    <ul>\n-        <li><a class=\"no-select\" href=\"/settings.html\">Profile</a></li>\n-        <li><a class=\"no-select\" href=\"/settings-notifications.html\">Notifications</a></li>\n-        <li><a class=\"no-select\" href=\"/settings-privacy.html\">Privacy</a></li> \n-    </ul>\n-\n-  </aside>\ndiff --git a/src/snek/view/settings.py b/src/snek/view/settings.py\ndeleted file mode 100644\nindex fe181f2..0000000\n--- a/src/snek/view/settings.py\n+++ /dev/null\n@@ -1,8 +0,0 @@\n-from snek.system.view import BaseView \n-\n-class SettingsView(BaseView):\n-    \n-    login_required = True\n-\n-    async def get(self):\n-        return await self.render_template('settings.html')\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 9a2b9e4..0068fcc 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,9 +1,8 @@\n import logging\n-\n import pathlib\n+\n logging.basicConfig(level=logging.DEBUG)\n \n-import asyncio\n import base64\n import datetime\n import mimetypes\n@@ -21,7 +20,7 @@ class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n         super().__init__(*args, **kwargs)\n         self.locks = {}\n-        \n+\n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n         self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n@@ -31,22 +30,21 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n         self.parent = parent\n \n-    @property \n+    @property\n     def db(self):\n         return self.parent.db\n \n-    @property \n+    @property\n     def services(self):\n-        return self.parent.services \n-\n+        return self.parent.services\n \n     async def authenticate(self, request):\n@@ -60,13 +58,17 @@ class WebdavApplication(aiohttp.web.Application):\n         encoded_creds = auth_header.split(\"Basic \")[1]\n         decoded_creds = base64.b64decode(encoded_creds).decode()\n         username, password = decoded_creds.split(\":\", 1)\n-        request['user'] = await self.services.user.authenticate(username=username, password=password)\n+        request[\"user\"] = await self.services.user.authenticate(\n+            username=username, password=password\n+        )\n         try:\n-            request['home'] = await self.services.user.get_home_folder(request['user']['uid'])\n+            request[\"home\"] = await self.services.user.get_home_folder(\n+                request[\"user\"][\"uid\"]\n+            )\n         except Exception as ex:\n             print(ex)\n             pass\n-        return request['user']\n+        return request[\"user\"]\n \n     async def handle_get(self, request):\n         if not await self.authenticate(request):\n@@ -75,7 +77,7 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request['home'] / requested_path\n+        abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"File not found\")\n@@ -95,7 +97,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -107,7 +109,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        file_path = request['home'] / request.match_info[\"filename\"]\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n         if file_path.is_file():\n             file_path.unlink()\n             return aiohttp.web.Response(status=204)\n@@ -121,7 +123,7 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        dir_path = request['home'] / request.match_info[\"filename\"]\n+        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n         if dir_path.exists():\n             return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n         dir_path.mkdir(parents=True, exist_ok=True)\n@@ -132,8 +134,8 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        src_path = request['home'] / request.match_info[\"filename\"]\n-        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n         )\n         if not src_path.exists():\n@@ -146,8 +148,8 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        src_path = request['home'] / request.match_info[\"filename\"]\n-        dest_path = request['home'] / request.headers.get(\"Destination\", \"\").replace(\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n         )\n         if not src_path.exists():\n@@ -188,14 +190,13 @@ class WebdavApplication(aiohttp.web.Application):\n         statvfs = os.statvfs(path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n-\n     async def create_node(self, request, response_xml, full_path, depth):\n-        requested_path = request.match_info.get(\"filename\", \"\") \n+        request.match_info.get(\"filename\", \"\")\n         abs_path = pathlib.Path(full_path)\n-        relative_path = str(full_path.relative_to(request['home']))\n+        relative_path = str(full_path.relative_to(request[\"home\"]))\n \n         href_path = f\"{relative_path}\".strip(\"/\")\n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n@@ -213,7 +214,7 @@ class WebdavApplication(aiohttp.web.Application):\n             else self.get_directory_size(full_path)\n         )\n         etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            self.get_disk_free_space(request['home'])\n+            self.get_disk_free_space(request[\"home\"])\n         )\n         etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n@@ -222,10 +223,10 @@ class WebdavApplication(aiohttp.web.Application):\n         if full_path.is_file():\n             etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n             etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-            full_path.stat().st_size\n-            if full_path.is_file()\n-            else self.get_directory_size(full_path)\n-        )\n+                full_path.stat().st_size\n+                if full_path.is_file()\n+                else self.get_directory_size(full_path)\n+            )\n         supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n         lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n         lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n@@ -238,13 +239,10 @@ class WebdavApplication(aiohttp.web.Application):\n         lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n-        \n+\n         if abs_path.is_dir() and depth != -1:\n             for item in abs_path.iterdir():\n-                await self.create_node(request,response_xml, item, depth - 1)\n-\n-\n-\n+                await self.create_node(request, response_xml, item, depth - 1)\n \n     async def handle_propfind(self, request):\n         if not await self.authenticate(request):\n@@ -257,14 +255,13 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n-        requested_path = request.match_info.get(\"filename\", \"\") \n-        abs_path = request['home'] / requested_path\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n         nsmap = {\"D\": \"DAV:\"}\n         response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n-        directories = [requested_path]\n-        \n+\n         await self.create_node(request, response_xml, abs_path, depth)\n \n         xml_output = etree.tostring(\n@@ -286,9 +283,9 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        resource = request.match_info.get(\"filename\", \"/\")\n+        request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n         xml_response = self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n@@ -341,8 +338,8 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        print(requested_path) \n-        abs_path = request['home'] / requested_path\n+        print(requested_path)\n+        abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"File not found\")\n@@ -363,5 +360,3 @@ class WebdavApplication(aiohttp.web.Application):\n         }\n \n         return aiohttp.web.Response(status=200, headers=headers)\n-\n-"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Added profile settings page with nickname and description editing", "commit": "69482207461eec9c3c64ec297231989aa248dd9a", "diff": "commit 69482207461eec9c3c64ec297231989aa248dd9a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 3 08:35:25 2025 +0200\n\n    Progress\n\ndiff --git a/.gitignore b/.gitignore\nindex ce8bd16..e83eedd 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -1,3 +1,4 @@\n+.r_history\n .vscode\n .history\n .resources\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nnew file mode 100644\nindex 0000000..24eb884\n--- /dev/null\n+++ b/src/snek/form/settings/profile.py\n@@ -0,0 +1,14 @@\n+from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement\n+\n+\n+class SettingsProfileForm(Form):\n+\n+    nick = FormInputElement(name=\"nick\", required=True, place_holder=\"Your Nickname\", min_length=1, max_length=20)\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n+    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n+    profile = FormInputElement(name=\"profile\", place_holder=\"Tell about yourself.\", required=False,max_length=300)\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nnew file mode 100644\nindex 0000000..7359f60\n--- /dev/null\n+++ b/src/snek/mapper/user_property.py\n@@ -0,0 +1,7 @@\n+from snek.model.user_property import UserPropertyModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class UserPropertyMapper(BaseMapper):\n+    table_name = \"user_property\"\n+    model_class = UserPropertyModel\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nnew file mode 100644\nindex 0000000..7f0113c\n--- /dev/null\n+++ b/src/snek/model/user_property.py\n@@ -0,0 +1,10 @@\n+import mimetypes\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class UserPropertyModel(BaseModel):\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    value = ModelField(name=\"path\", required=True, kind=str)\n+    \ndiff --git a/src/snek/templates/app_menu.html b/src/snek/templates/app_menu.html\nnew file mode 100644\nindex 0000000..ffcdfd5\n--- /dev/null\n+++ b/src/snek/templates/app_menu.html\n@@ -0,0 +1,13 @@\n+  <div>\n+    <div class=\"logo no-select\">Test</div>\n+    <nav class=\"no-select\" style=\"float:right;overflow:hidden;scroll-behavior:smooth\">\n+      <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n+      <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\n+      <a class=\"no-select\" href=\"/logout.html\">\ud83d\udd12</a>\n+    </nav>\n+\n+  </div>\n+\ndiff --git a/src/snek/templates/settings/index.html b/src/snek/templates/settings/index.html\nnew file mode 100644\nindex 0000000..f91fc5d\n--- /dev/null\n+++ b/src/snek/templates/settings/index.html\n@@ -0,0 +1,37 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+{% include \"settings/sidebar.html\" %}\n+\n+{% endblock %}\n+\n+\n+{% block head %}\n+\n+\n+{% endblock %}\n+\n+{% block main %}\n+\n+\n+<div id=\"profile_description\"></div>\n+\n+\n+\n+<script type=\"module\">\n+\n+\n+        require(['vs/editor/editor.main'], function () {\n+var editor = monaco.editor.create(document.getElementById('profile_description'), {\n+                value: phpCode,\n+                language: 'php'\n+            });\n+        })\n+</script>\n+\n+{% endblock main %}\ndiff --git a/src/snek/templates/settings/profile.html b/src/snek/templates/settings/profile.html\nnew file mode 100644\nindex 0000000..964f9b7\n--- /dev/null\n+++ b/src/snek/templates/settings/profile.html\n@@ -0,0 +1,31 @@\n+{% extends \"settings/index.html\" %}\n+\n+\n+{% block main %}\n+<section>\n+<form>\n+    <h2>Nickname</h2>\n+    \n+<input type=\"text\" name=\"nick\" placeholder=\"Your nickname\" value=\"{{ user.nick.value }}\" />\n+\n+</form>\n+<h2>Description</h2>\n+\n+\n+<textarea id=\"profile\"></textarea>\n+</section>\n+<script>\n+    const easyMDE = new EasyMDE({element:document.getElementById(\"profile\")});\n+    </script>\n+<style>\n+\n+.EasyMDEContainer {\n+        filter: invert(1) !important;\n+     \n+    }\n+    \n+    </style>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/sidebar.html b/src/snek/templates/settings/sidebar.html\nnew file mode 100644\nindex 0000000..f9533be\n--- /dev/null\n+++ b/src/snek/templates/settings/sidebar.html\n@@ -0,0 +1,9 @@\n+\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>You</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/settings/profile.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/settings/gists.html\">Gists</a></li>\n+    </ul>\n+\n+  </aside>\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nnew file mode 100644\nindex 0000000..1e45b56\n--- /dev/null\n+++ b/src/snek/view/settings/index.py\n@@ -0,0 +1,8 @@\n+from snek.system.view import BaseView \n+\n+class SettingsIndexView(BaseView):\n+    \n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template('settings/index.html')\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nnew file mode 100644\nindex 0000000..4e6638c\n--- /dev/null\n+++ b/src/snek/view/settings/profile.py\n@@ -0,0 +1,36 @@\n+from snek.system.view import BaseView,BaseFormView\n+\n+from snek.form.settings.profile import SettingsProfileForm\n+from aiohttp import web\n+\n+\n+class SettingsProfileView(BaseFormView):\n+    form = SettingsProfileForm\n+\n+    login_required = True\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+            \n+        if self.request.path.endswith(\".json\"):\n+            form['nick'] = self.request['user']['nick']\n+            return web.json_response(await form.to_json()) \n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+\n+        return await self.render_template(\n+            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n+        )\n+\n+    async def submit(self, form):\n+        post = await self.request.json()\n+        form.set_user_data(post[\"form\"])\n+\n+        if await form.is_valid:\n+            user = self.request['user']\n+            user[\"nick\"] = form[\"nick\"]\n+            await self.services.user.save(user)\n+            return {\"redirect_url\": \"/settings/profile.html\"}\n+        return {\"is_valid\": False}\n+"}
{"repo": ".", "date": "2025-04-06", "line": "fix: Update icons in manifest for stability on Firefox Android", "commit": "c2b8061ac292f18949d81c660c5a314cb42bcc6e", "diff": "commit c2b8061ac292f18949d81c660c5a314cb42bcc6e\nAuthor: BordedDev <>\nDate:   Sun Apr 6 23:47:18 2025 +0200\n\n    Potential fix for manifest, the icons were being marked as instability since they were the wrong size which might fix firefox android\n\ndiff --git a/src/snek/static/image/snek192.png b/src/snek/static/image/snek192.png\nnew file mode 100644\nindex 0000000..4e044c8\nBinary files /dev/null and b/src/snek/static/image/snek192.png differ\ndiff --git a/src/snek/static/image/snek512.png b/src/snek/static/image/snek512.png\nnew file mode 100644\nindex 0000000..d3aade5\nBinary files /dev/null and b/src/snek/static/image/snek512.png differ\ndiff --git a/src/snek/static/manifest.json b/src/snek/static/manifest.json\nindex faa7381..749df05 100644\n--- a/src/snek/static/manifest.json\n+++ b/src/snek/static/manifest.json\n@@ -17,12 +17,12 @@\n     \"start_url\": \"/web.html\",\n     \"icons\": [\n       {\n-        \"src\": \"/image/snek1.png\",\n+        \"src\": \"/image/snek192.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"192x192\"\n       },\n         {\n-        \"src\": \"/image/snek1.png\",\n+        \"src\": \"/image/snek512.png\",\n         \"type\": \"image/png\",\n         \"sizes\": \"512x512\"\n       }"}
{"repo": ".", "date": "2025-04-07", "line": "fix: Resolved web manifest icon instability on Firefox Android", "commit": "75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d", "diff": "commit 75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d\nMerge: 6948220 c2b8061\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Mon Apr 7 11:24:13 2025 +0000\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added IPython dependency and improved asyncio handling", "commit": "d71d5da6bcf22d2daf5ec59832f15fe02472b95c", "diff": "commit d71d5da6bcf22d2daf5ec59832f15fe02472b95c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 04:20:28 2025 +0200\n\n    Updates.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 6fbf200..62c1ac7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -16,7 +16,7 @@ requires-python = \">=3.12\"\n dependencies = [\n     \"mkdocs>=1.4.0\",\n     \"lxml\",\n-\n+    \"IPython\",\n     \"shed\",\n     \"beautifulsoup4\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 25913b9..ead9ff8 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -85,12 +85,18 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(EmojiExtension)\n \n         self.setup_router()\n-\n+        self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n+    async def prepare_asyncio(self,app):\n+        app.executor = ThreadPoolExecutor(max_workers=200)\n+        app.loop.set_default_executor(self.executor)   \n+\n     async def create_task(self, task):\n         await self.tasks.put(task)\n \n@@ -235,11 +241,6 @@ class Application(BaseApplication):\n         return await super().render_template(template, request, context)\n \n \n-executor = ThreadPoolExecutor(max_workers=200)\n-\n-loop = asyncio.get_event_loop()\n-loop.set_default_executor(executor)\n-\n \n \ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex a35d890..89d46ba 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,6 +29,28 @@ class UserModel(BaseModel):\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n+    async def get_property(self, name):\n+        prop = await self.app.services.user_property.find_one(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+        if prop:\n+            return prop[\"value\"]\n+\n+    async def has_property(self, name):\n+        return await self.app.services.user_property.exists(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+\n+    async def set_property(self, name, value):\n+        if not await self.has_property(name):\n+            await self.app.services.user_property.insert(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+        else:\n+            await self.app.services.user_property.update(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+\n     async def get_channel_members(self):\n         async for channel_member in self.app.services.channel_member.find(\n             user_uid=self[\"uid\"], is_banned=False, deleted_at=None\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 4059f77..f491e9b 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -11,7 +11,7 @@ from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.util import UtilService\n from snek.system.object import Object\n-\n+from snek.service.user_property import UserPropertyService\n \n @functools.cache\n def get_services(app):\n@@ -27,6 +27,7 @@ def get_services(app):\n             \"util\": UtilService(app=app),\n             \"drive\": DriveService(app=app),\n             \"drive_item\": DriveItemService(app=app),\n+            \"user_property\": UserPropertyService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 95e5bc4..0517ff9 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -233,4 +233,4 @@ export class App extends EventHandler {\n }\n \n export const app = new App();\n-window.app = app;\n\\ No newline at end of file\n+window.app = app;\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 7384ad7..f009c71 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -374,15 +374,12 @@ a {\n         left: 0;\n             text-overflow: ellipsis;\n         width:100%;\n-        *{\n-            font-size: 12px !important;\n-        }\n         .logo {\n             text-overflow: ellipsis;\n             white-space: nowrap;\n             overflow: hidden;\n             h2 {\n-                font-size: 12px;\n+                font-size: 14px;\n             }\n         }\n         \ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex cf35d0d..4aad6eb 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -2,6 +2,8 @@\n \n {% block title %}Search{% endblock %}\n \n+\n {% block main %}\n \n <section class=\"chat-area\">\ndiff --git a/src/snek/templates/threads.html b/src/snek/templates/threads.html\nindex ae71c6f..d534df6 100644\n--- a/src/snek/templates/threads.html\n+++ b/src/snek/templates/threads.html\n@@ -1,7 +1,10 @@\n {% extends \"app.html\" %}\n \n+\n {% block main %}\n <section class=\"chat-area\" id=\"chat\">\n+    <div class=\"chat-header\">&nbsp;</div>\n     <div class=\"threads\">\n         {% for thread in threads %}\n         {% autoescape false %}"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Implement user property service for storing and retrieving user-specific data", "commit": "d2e2bb811707b02f05cbf22d10ef1916b021c90d", "diff": "commit d2e2bb811707b02f05cbf22d10ef1916b021c90d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 05:01:27 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nnew file mode 100644\nindex 0000000..7531577\n--- /dev/null\n+++ b/src/snek/service/user_property.py\n@@ -0,0 +1,34 @@\n+import pathlib\n+import json \n+from snek.system import security\n+from snek.system.service import BaseService\n+\n+\n+class UserPropertyService(BaseService):\n+    mapper_name = \"user_property\"\n+\n+    async def set(self, user_uid, name, value):\n+        prop = await self.get(user_uid=user_uid, name=name)\n+        if not prop:\n+            prop = await self.new()\n+            prop[\"user_uid\"] = user_uid\n+            prop[\"name\"] = name\n+\n+        prop[\"value\"] = json.dumps(value,default=str)\n+        return await self.save(prop)\n+    \n+    async def get(self, user_uid, name):\n+        try:\n+                return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n+        except:\n+            return None\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\n+"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added debug middleware and improved routing in WebdavApplication", "commit": "d23ed3711a464f1d796ed35e58dbcaf1db6b7d84", "diff": "commit d23ed3711a464f1d796ed35e58dbcaf1db6b7d84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 20:31:15 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 0068fcc..26dce2f 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -15,11 +15,21 @@ import aiohttp\n import aiohttp.web\n from lxml import etree\n \n+@aiohttp.web.middleware\n+async def debug_middleware(request, handler):\n+    print(request.method, request.path, request.headers)\n+    return await handler(request)\n+\n \n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n+        middlewares = [debug_middleware]\n+\n+        super().__init__(middlewares=middlewares, *args, **kwargs)\n         self.locks = {}\n+        \n+        self.relative_url = \"/webdav\"\n+        print(self.router)\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n@@ -30,8 +40,8 @@ class WebdavApplication(aiohttp.web.Application):\n         self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n         self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n         self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n         self.parent = parent\n \n     @property\n@@ -46,11 +56,11 @@ class WebdavApplication(aiohttp.web.Application):\n \n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):\n@@ -66,6 +76,7 @@ class WebdavApplication(aiohttp.web.Application):\n                 request[\"user\"][\"uid\"]\n             )\n         except Exception as ex:\n+            print(\"GRRRRRRRRRR\")\n             print(ex)\n             pass\n         return request[\"user\"]\n@@ -98,6 +109,7 @@ class WebdavApplication(aiohttp.web.Application):\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n         file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        print(\"WRITETO_\", file_path)\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -195,9 +207,11 @@ class WebdavApplication(aiohttp.web.Application):\n         abs_path = pathlib.Path(full_path)\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n-        href_path = f\"{relative_path}\".strip(\"/\")\n+        \n+        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n+        href_path = href_path.replace(\"./\",\"/\")\n+            \n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n         href.text = href_path\n@@ -240,7 +254,7 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n \n-        if abs_path.is_dir() and depth != -1:\n+        if abs_path.is_dir():\n             for item in abs_path.iterdir():\n                 await self.create_node(request, response_xml, item, depth - 1)\n \n@@ -255,7 +269,11 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n+        \n+        print(request)\n+\n         requested_path = request.match_info.get(\"filename\", \"\")\n+        \n         abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n@@ -267,6 +285,7 @@ class WebdavApplication(aiohttp.web.Application):\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()\n+        print(xml_output)\n         return aiohttp.web.Response(\n             status=207, text=xml_output, content_type=\"application/xml\"\n         )"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Add debug middleware and improve WebDAV handling\n\nThis commit introduces a debug middleware for request logging and enhances WebDAV functionality with several improvements:\n\n-   Added debug middleware to log request details.\n-   Implemented caching for file size and disk space retrieval.\n-   Fixed potential errors in user authentication and home folder retrieval.\n-   Improved error handling and logging.\n-   Refactored code for better readability and maintainability.\n-   Added async support for file size retrieval.\n", "commit": "13f1d2f390afdfc912d24bb63930c9ca47e05f94", "diff": "commit 13f1d2f390afdfc912d24bb63930c9ca47e05f94\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue Apr 8 21:32:18 2025 +0200\n\n    Performance upgrade, lock fix.\n\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 26dce2f..f982e77 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,8 +1,8 @@\n import logging\n import pathlib\n-\n+import asyncio\n logging.basicConfig(level=logging.DEBUG)\n-\n+from app.cache import time_cache,time_cache_async\n import base64\n import datetime\n import mimetypes\n@@ -18,8 +18,13 @@ from lxml import etree\n @aiohttp.web.middleware\n async def debug_middleware(request, handler):\n     print(request.method, request.path, request.headers)\n-    return await handler(request)\n-\n+    result = await handler(request)\n+    print(result.status)\n+    try:\n+        print(await result.text())\n+    except:\n+        pass\n+    return result\n \n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n@@ -29,7 +34,6 @@ class WebdavApplication(aiohttp.web.Application):\n         self.locks = {}\n         \n         self.relative_url = \"/webdav\"\n-        print(self.router)\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n         self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n@@ -53,15 +57,6 @@ class WebdavApplication(aiohttp.web.Application):\n         return self.parent.services\n \n     async def authenticate(self, request):\n-\n         auth_header = request.headers.get(\"Authorization\", \"\")\n         if not auth_header.startswith(\"Basic \"):\n             return False\n@@ -75,9 +70,7 @@ class WebdavApplication(aiohttp.web.Application):\n             request[\"home\"] = await self.services.user.get_home_folder(\n                 request[\"user\"][\"uid\"]\n             )\n-        except Exception as ex:\n-            print(\"GRRRRRRRRRR\")\n-            print(ex)\n+        except Exception:\n             pass\n         return request[\"user\"]\n \n@@ -109,7 +102,6 @@ class WebdavApplication(aiohttp.web.Application):\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n         file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        print(\"WRITETO_\", file_path)\n         file_path.parent.mkdir(parents=True, exist_ok=True)\n         async with aiofiles.open(file_path, \"wb\") as f:\n             while chunk := await request.content.read(1024):\n@@ -177,7 +169,6 @@ class WebdavApplication(aiohttp.web.Application):\n             \"DAV\": \"1, 2\",\n             \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n         }\n-        print(\"RETURN\")\n         return aiohttp.web.Response(status=200, headers=headers)\n \n     def get_current_utc_time(self, filepath):\n@@ -189,25 +180,33 @@ class WebdavApplication(aiohttp.web.Application):\n             \"%a, %d %b %Y %H:%M:%S GMT\"\n         )\n \n-    def get_directory_size(self, directory):\n+    @time_cache_async(10)\n+    async def get_file_size(self, path):\n+        loop = self.parent.loop \n+        stat = await loop.run_in_executor(None,os.stat, path)\n+        return stat.st_size\n+    \n+    @time_cache_async(10)\n+    async def get_directory_size(self, directory):\n         total_size = 0\n         for dirpath, _, filenames in os.walk(directory):\n             for f in filenames:\n                 fp = pathlib.Path(dirpath) / f\n                 if fp.exists():\n-                    total_size += fp.stat().st_size\n+                    total_size += await self.get_file_size(str(fp))\n         return total_size\n \n-    def get_disk_free_space(self, path):\n-        statvfs = os.statvfs(path)\n+\n+    @time_cache_async(30) \n+    async def get_disk_free_space(self, path=\"/\"):\n+        loop = self.parent.loop \n+        statvfs = await loop.run_in_executor(None,os.statvfs, path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n     async def create_node(self, request, response_xml, full_path, depth):\n-        request.match_info.get(\"filename\", \"\")\n         abs_path = pathlib.Path(full_path)\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n-        \n         href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n         href_path = href_path.replace(\"./\",\"/\")\n@@ -223,12 +222,12 @@ class WebdavApplication(aiohttp.web.Application):\n         creation_date, last_modified = self.get_current_utc_time(full_path)\n         etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n         etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-            full_path.stat().st_size\n+            await self.get_file_size(full_path)\n             if full_path.is_file()\n-            else self.get_directory_size(full_path)\n+            else await self.get_directory_size(full_path)\n         )\n         etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            self.get_disk_free_space(request[\"home\"])\n+            await self.get_disk_free_space(request[\"home\"])\n         )\n         etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n         etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n@@ -237,9 +236,9 @@ class WebdavApplication(aiohttp.web.Application):\n         if full_path.is_file():\n             etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n             etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                full_path.stat().st_size\n+                await self.get_file_size(full_path)\n                 if full_path.is_file()\n-                else self.get_directory_size(full_path)\n+                else await self.get_directory_size(full_path)\n             )\n         supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n         lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n@@ -254,7 +253,7 @@ class WebdavApplication(aiohttp.web.Application):\n         etree.SubElement(lock_type_2, \"{DAV:}write\")\n         etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n \n-        if abs_path.is_dir():\n+        if abs_path.is_dir() and depth > 0:\n             for item in abs_path.iterdir():\n                 await self.create_node(request, response_xml, item, depth - 1)\n \n@@ -269,8 +268,6 @@ class WebdavApplication(aiohttp.web.Application):\n             depth = int(request.headers.get(\"Depth\", \"0\"))\n         except ValueError:\n             pass\n-        \n-        print(request)\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n         \n@@ -285,7 +282,6 @@ class WebdavApplication(aiohttp.web.Application):\n         xml_output = etree.tostring(\n             response_xml, encoding=\"utf-8\", xml_declaration=True\n         ).decode()\n-        print(xml_output)\n         return aiohttp.web.Response(\n             status=207, text=xml_output, content_type=\"application/xml\"\n         )\n@@ -302,10 +298,10 @@ class WebdavApplication(aiohttp.web.Application):\n             return aiohttp.web.Response(\n                 status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n             )\n-        request.match_info.get(\"filename\", \"/\")\n+        resource = request.match_info.get(\"filename\", \"/\")\n         lock_id = str(uuid.uuid4())\n-        xml_response = self.generate_lock_response(lock_id)\n+        self.locks[resource] = lock_id\n+        xml_response = await self.generate_lock_response(lock_id)\n         headers = {\n             \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n             \"Content-Type\": \"application/xml\",\n@@ -320,13 +316,13 @@ class WebdavApplication(aiohttp.web.Application):\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n             \"opaquelocktoken:\", \"\"\n-        )\n+            )[1:-1]\n         if self.locks.get(resource) == lock_token:\n             del self.locks[resource]\n             return aiohttp.web.Response(status=204)\n         return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n \n-    def generate_lock_response(self, lock_id):\n+    async def generate_lock_response(self, lock_id):\n         nsmap = {\"D\": \"DAV:\"}\n         root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n         lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n@@ -357,7 +353,6 @@ class WebdavApplication(aiohttp.web.Application):\n             )\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        print(requested_path)\n         abs_path = request[\"home\"] / requested_path\n \n         if not abs_path.exists():"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Support short and standard YouTube links in template", "commit": "b31c286a8b8442d48f9b0713a8cce41432c168d1", "diff": "commit b31c286a8b8442d48f9b0713a8cce41432c168d1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:34:57 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex cff807b..7ee0d0f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,10 +91,10 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"?v=\" in element.attrs[\"href\"]\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "b0a97ad267b971f8ba298bb5a0e696810c08b026", "diff": "commit b0a97ad267b971f8ba298bb5a0e696810c08b026\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:35:15 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7ee0d0f..2974afc 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,7 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n+            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n             video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding with improved parsing", "commit": "c6575d8e525dfa2f574e1965cd3df4379cde7acd", "diff": "commit c6575d8e525dfa2f574e1965cd3df4379cde7acd\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:43:46 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 2974afc..5d96739 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,10 +91,16 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = \"?\" + \"?\".join(element.attrs[\"href\"].split(\"?\")[1:])\n+            video_name = None \n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            if \"si=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?si=\")[1].split(\"&\")[0]\n+            if \"t=\" in element.attrs[\"href\"]:\n+                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube video name extraction", "commit": "6138cad7827c48a86b20d4015dce818dca348f04", "diff": "commit 6138cad7827c48a86b20d4015dce818dca348f04\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:46:53 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 5d96739..f844900 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -93,13 +93,14 @@ def embed_youtube(text):\n             and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = None \n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            if \"si=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?si=\")[1].split(\"&\")[0]\n-            if \"t=\" in element.attrs[\"href\"]:\n-                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            \n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Style YouTube embed to center horizontally", "commit": "087f9c10b44fca9f29de862562b405ef5586f151", "diff": "commit 087f9c10b44fca9f29de862562b405ef5586f151\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:52:39 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex f844900..fd7c8b8 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -101,7 +101,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add support for YouTube video embedding", "commit": "e6bd7aa15211ae0bd3be65be3a659526b1131eee", "diff": "commit e6bd7aa15211ae0bd3be65be3a659526b1131eee\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:55:30 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex fd7c8b8..853e2f1 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,6 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Embed YouTube videos with a container div", "commit": "94e94cf7ca4bdcdd581dfe074728e93412c2a621", "diff": "commit 94e94cf7ca4bdcdd581dfe074728e93412c2a621\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:56:52 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 853e2f1..8b5e8b1 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -100,7 +100,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "6673f7b615508f0c344fe0efbebe362f5236bd84", "diff": "commit 6673f7b615508f0c344fe0efbebe362f5236bd84\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 10:59:09 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8b5e8b1..c81ad9f 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -100,7 +100,7 @@ def embed_youtube(text):\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding with improved URL parsing", "commit": "2582df360ab0667a3d29c46b92ad4abeb397d363", "diff": "commit 2582df360ab0667a3d29c46b92ad4abeb397d363\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:02:45 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex c81ad9f..08a2b93 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,16 +91,16 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n+            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n         ):\n-            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n-            \n+            video_name = None \n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            if \"si=\" in element.attrs[\"href\"]:\n+                video_name = \"?v=\" + element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"t=\" in element.attrs[\"href\"]:\n+                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "656ea5f90ee56a16b0f0047cace848572dc479c7", "diff": "commit 656ea5f90ee56a16b0f0047cace848572dc479c7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:03:39 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 08a2b93..1a1175e 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,7 +91,7 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and \"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"]\n+            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n             video_name = None \n             if \"v=\" in element.attrs[\"href\"]:"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube embedding logic", "commit": "c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b", "diff": "commit c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:07:09 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1a1175e..d04f56e 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -91,16 +91,15 @@ def embed_youtube(text):\n     for element in soup.find_all(\"a\"):\n         if (\n-            and (\"v=\" in element.attrs[\"href\"] or \"si=\" in element.attrs[\"href\"])\n         ):\n-            video_name = None \n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            if \"si=\" in element.attrs[\"href\"]:\n-                video_name = \"?v=\" + element.attrs[\"href\"].split(\"/\")[-1]\n-            if \"t=\" in element.attrs[\"href\"]:\n-                video_name += \"&t=\" + element.attrs[\"href\"].split(\"&t=\")[1].split(\"&\")[0]\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a", "diff": "commit 8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 11:09:00 2025 +0200\n\n    New video embedding\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d04f56e..8a1202d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -93,13 +93,13 @@ def embed_youtube(text):\n         ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Refactor asyncio and database preparation in Application class", "commit": "44dd77cec5639575cb86973eceb8d174d570370c", "diff": "commit 44dd77cec5639575cb86973eceb8d174d570370c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 15:12:34 2025 +0200\n\n    Shed.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ead9ff8..a019d74 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -92,10 +92,10 @@ class Application(BaseApplication):\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n-    async def prepare_asyncio(self,app):\n+    async def prepare_asyncio(self, app):\n         app.executor = ThreadPoolExecutor(max_workers=200)\n-        app.loop.set_default_executor(self.executor)   \n+        app.loop.set_default_executor(self.executor)\n \n     async def create_task(self, task):\n         await self.tasks.put(task)\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 24eb884..836cd67 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,14 +1,25 @@\n-from snek.system.form import Form, FormInputElement, FormButtonElement, HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n \n \n class SettingsProfileForm(Form):\n \n-    nick = FormInputElement(name=\"nick\", required=True, place_holder=\"Your Nickname\", min_length=1, max_length=20)\n+    nick = FormInputElement(\n+        name=\"nick\",\n+        required=True,\n+        place_holder=\"Your Nickname\",\n+        min_length=1,\n+        max_length=20,\n+    )\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n     )\n     title = HTMLElement(tag=\"h1\", text=\"Profile\")\n-    profile = FormInputElement(name=\"profile\", place_holder=\"Tell about yourself.\", required=False,max_length=300)\n+    profile = FormInputElement(\n+        name=\"profile\",\n+        place_holder=\"Tell about yourself.\",\n+        required=False,\n+        max_length=300,\n+    )\n     action = FormButtonElement(\n         name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n\\ No newline at end of file\n+    )\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 7f0113c..1231423 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,5 +1,3 @@\n-import mimetypes\n-\n from snek.system.model import BaseModel, ModelField\n \n \n@@ -7,4 +5,3 @@ class UserPropertyModel(BaseModel):\n     user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n     name = ModelField(name=\"name\", required=True, kind=str)\n     value = ModelField(name=\"path\", required=True, kind=str)\n-    \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex f491e9b..3ec4592 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -9,9 +9,10 @@ from snek.service.drive_item import DriveItemService\n from snek.service.notification import NotificationService\n from snek.service.socket import SocketService\n from snek.service.user import UserService\n+from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.system.object import Object\n-from snek.service.user_property import UserPropertyService\n+\n \n @functools.cache\n def get_services(app):\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 7531577..e95d62e 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,6 +1,5 @@\n-import pathlib\n-import json \n-from snek.system import security\n+import json\n+\n from snek.system.service import BaseService\n \n \n@@ -14,12 +13,12 @@ class UserPropertyService(BaseService):\n             prop[\"user_uid\"] = user_uid\n             prop[\"name\"] = name\n \n-        prop[\"value\"] = json.dumps(value,default=str)\n+        prop[\"value\"] = json.dumps(value, default=str)\n         return await self.save(prop)\n-    \n+\n     async def get(self, user_uid, name):\n         try:\n-                return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n+            return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n         except:\n             return None\n \n@@ -31,4 +30,3 @@ class UserPropertyService(BaseService):\n         async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)\n         return results\n-\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 8a1202d..d4b6819 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -89,15 +89,13 @@ def set_link_target_blank(text):\n def embed_youtube(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"a\"):\n-        if (\n-        ):\n             video_name = element.attrs[\"href\"].split(\"/\")[-1]\n             if \"v=\" in element.attrs[\"href\"]:\n                 video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex 1e45b56..418ef3d 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,8 +1,9 @@\n-from snek.system.view import BaseView \n+from snek.system.view import BaseView\n+\n \n class SettingsIndexView(BaseView):\n-    \n+\n     login_required = True\n \n     async def get(self):\n-        return await self.render_template('settings/index.html')\n+        return await self.render_template(\"settings/index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4e6638c..4a98a9a 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,7 +1,7 @@\n-from snek.system.view import BaseView,BaseFormView\n+from aiohttp import web\n \n from snek.form.settings.profile import SettingsProfileForm\n-from aiohttp import web\n+from snek.system.view import BaseFormView\n \n \n class SettingsProfileView(BaseFormView):\n@@ -11,13 +11,12 @@ class SettingsProfileView(BaseFormView):\n \n     async def get(self):\n         form = self.form(app=self.app)\n-            \n+\n         if self.request.path.endswith(\".json\"):\n-            form['nick'] = self.request['user']['nick']\n-            return web.json_response(await form.to_json()) \n-        \n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+            return web.json_response(await form.to_json())\n \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n             \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n@@ -28,9 +27,8 @@ class SettingsProfileView(BaseFormView):\n         form.set_user_data(post[\"form\"])\n \n         if await form.is_valid:\n-            user = self.request['user']\n+            user = self.request[\"user\"]\n             user[\"nick\"] = form[\"nick\"]\n             await self.services.user.save(user)\n             return {\"redirect_url\": \"/settings/profile.html\"}\n         return {\"is_valid\": False}\n-\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex f982e77..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,8 +1,7 @@\n import logging\n import pathlib\n-import asyncio\n+\n logging.basicConfig(level=logging.DEBUG)\n-from app.cache import time_cache,time_cache_async\n import base64\n import datetime\n import mimetypes\n@@ -13,8 +12,10 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n+from app.cache import time_cache_async\n from lxml import etree\n \n+\n @aiohttp.web.middleware\n async def debug_middleware(request, handler):\n     print(request.method, request.path, request.headers)\n@@ -26,13 +27,14 @@ async def debug_middleware(request, handler):\n         pass\n     return result\n \n+\n class WebdavApplication(aiohttp.web.Application):\n     def __init__(self, parent, *args, **kwargs):\n         middlewares = [debug_middleware]\n \n         super().__init__(middlewares=middlewares, *args, **kwargs)\n         self.locks = {}\n-        \n+\n         self.relative_url = \"/webdav\"\n \n         self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n@@ -182,10 +184,10 @@ class WebdavApplication(aiohttp.web.Application):\n \n     @time_cache_async(10)\n     async def get_file_size(self, path):\n-        loop = self.parent.loop \n-        stat = await loop.run_in_executor(None,os.stat, path)\n+        loop = self.parent.loop\n+        stat = await loop.run_in_executor(None, os.stat, path)\n         return stat.st_size\n-    \n+\n     @time_cache_async(10)\n     async def get_directory_size(self, directory):\n         total_size = 0\n@@ -196,11 +198,10 @@ class WebdavApplication(aiohttp.web.Application):\n                     total_size += await self.get_file_size(str(fp))\n         return total_size\n \n-\n-    @time_cache_async(30) \n+    @time_cache_async(30)\n     async def get_disk_free_space(self, path=\"/\"):\n-        loop = self.parent.loop \n-        statvfs = await loop.run_in_executor(None,os.statvfs, path)\n+        loop = self.parent.loop\n+        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n         return statvfs.f_bavail * statvfs.f_frsize\n \n     async def create_node(self, request, response_xml, full_path, depth):\n@@ -208,9 +209,9 @@ class WebdavApplication(aiohttp.web.Application):\n         relative_path = str(full_path.relative_to(request[\"home\"]))\n \n         href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n-        href_path = href_path.replace(\"./\",\"/\")\n+        href_path = href_path.replace(\"./\", \"/\")\n-            \n+\n         response = etree.SubElement(response_xml, \"{DAV:}response\")\n         href = etree.SubElement(response, \"{DAV:}href\")\n         href.text = href_path\n@@ -270,7 +271,7 @@ class WebdavApplication(aiohttp.web.Application):\n             pass\n \n         requested_path = request.match_info.get(\"filename\", \"\")\n-        \n+\n         abs_path = request[\"home\"] / requested_path\n         if not abs_path.exists():\n             return aiohttp.web.Response(status=404, text=\"Directory not found\")\n@@ -316,7 +317,7 @@ class WebdavApplication(aiohttp.web.Application):\n         resource = request.match_info.get(\"filename\", \"/\")\n         lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n             \"opaquelocktoken:\", \"\"\n-            )[1:-1]\n+        )[1:-1]\n         if self.locks.get(resource) == lock_token:\n             del self.locks[resource]\n             return aiohttp.web.Response(status=204)"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Minor formatting adjustments across modules", "commit": "743593affe276ae8ffd3751c80fe88eb4c99ac7f", "diff": "commit 743593affe276ae8ffd3751c80fe88eb4c99ac7f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Wed Apr 9 15:21:23 2025 +0200\n\n    Formatting.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a019d74..6ffee4f 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,8 @@ from aiohttp_session import (\n     setup as session_setup,\n )\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-from app.app import Application as BaseApplication\n \n+from app.app import Application as BaseApplication\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 50a4245..dcbd6f8 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,8 +1,8 @@\n import pathlib\n \n from aiohttp import web\n-from app.app import Application as BaseApplication\n \n+from app.app import Application as BaseApplication\n from snek.system.markdown import MarkdownExtension\n \n \ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex a1e87a4..2b59636 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -32,9 +32,10 @@ from urllib.parse import urljoin\n \n import aiohttp\n import imgkit\n-from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n \n+from app.cache import time_cache_async\n+\n \n async def crc32(data):\n     try:\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..53b5db8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -2,12 +2,13 @@\n \n from types import SimpleNamespace\n \n-from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n \n+from app.cache import time_cache_async\n+\n \n class MarkdownRenderer(HTMLRenderer):\n \ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 4c57fab..6025038 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -12,9 +12,10 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n-from app.cache import time_cache_async\n from lxml import etree\n \n+from app.cache import time_cache_async\n+\n \n @aiohttp.web.middleware\n async def debug_middleware(request, handler):"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Removed comments and added channel listing to sidebar", "commit": "0e6fbd523cd4f4279a4f230567504b30c9b3116d", "diff": "commit 0e6fbd523cd4f4279a4f230567504b30c9b3116d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 10 08:37:05 2025 +0200\n\n    update.\n\ndiff --git a/src/snek/templates/sidebar_channels.html b/src/snek/templates/sidebar_channels.html\nindex bf3cab1..5612696 100644\n--- a/src/snek/templates/sidebar_channels.html\n+++ b/src/snek/templates/sidebar_channels.html\n@@ -4,12 +4,12 @@\n   }\n </style>\n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    \n     <h2 class=\"no-select\">Terminals</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/terminal.html\">Ubuntu</a></li>\n     </ul>\n+\n     {% if channels %}\n     <h2 class=\"no-select\">Channels</h2>\n     <ul>"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Improved channel broadcasting and added user UID retrieval\n\nThis commit enhances the channel broadcasting mechanism by retrieving user UIDs directly from the channel member service. It also introduces a new method `get_user_uids` in the `ChannelMemberService` for efficient UID retrieval. Error handling has been improved in `SocketService` and `RPCView`.", "commit": "3594ac1f5984953487e0c3423c9672b01e416c28", "diff": "commit 3594ac1f5984953487e0c3423c9672b01e416c28\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 10 13:34:32 2025 +0200\n\n    Performance upgrade.\n\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex 16d1887..a2300b6 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -10,6 +10,10 @@ class ChannelMemberService(BaseService):\n         channel_member[\"new_count\"] = 0\n         return await self.save(channel_member)\n \n+    async def get_user_uids(self, channel_uid):\n+        async for model in self.mapper.query(\"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\", {\"channel_uid\": channel_uid}):\n+            yield model[\"user_uid\"]\n+    \n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 072a86f..c084eb9 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -16,9 +16,8 @@ class SocketService(BaseService):\n             try:\n                 await self.ws.send_json(data)\n             except Exception as ex:\n-                print(ex, flush=True)\n                 self.is_connected = False\n-            return True\n+            return self.is_connected\n \n         async def close(self):\n             if not self.is_connected:\n@@ -43,7 +42,6 @@ class SocketService(BaseService):\n         self.users[user_uid].add(s)\n \n     async def subscribe(self, ws, channel_uid, user_uid):\n-        return\n         if channel_uid not in self.subscriptions:\n             self.subscriptions[channel_uid] = set()\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n@@ -57,10 +55,12 @@ class SocketService(BaseService):\n         return count\n \n     async def broadcast(self, channel_uid, message):\n-        async for channel_member in self.app.services.channel_member.find(\n-            channel_uid=channel_uid\n-        ):\n-            await self.send_to_user(channel_member[\"user_uid\"], message)\n+        try:\n+            async for user_uid in self.services.channel_member.get_user_uids(channel_uid):\n+                print(user_uid, flush=True)\n+                await self.send_to_user(user_uid, message)\n+        except Exception as ex:\n+            print(ex, flush=True)\n         return True\n \n     async def delete(self, ws):\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 19c98d4..3161f49 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -273,7 +273,7 @@ class RPCView(BaseView):\n                     async with Profiler():\n                         await rpc(msg.json())\n                 except Exception as ex:\n-                    print(ex, flush=True)\n+                    print(\"Deleting socket\", ex, flush=True)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Add stats view and cache statistics tracking", "commit": "bc65752ea252cdcd929ba0bd956455317958337a", "diff": "commit bc65752ea252cdcd929ba0bd956455317958337a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 05:06:53 2025 +0200\n\n    Cache stats.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 6ffee4f..cb4de21 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -44,6 +44,7 @@ from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n+from snek.view.stats import StatsView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -169,6 +170,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/terminal.html\", TerminalView)\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.router.add_view(\"/stats.json\", StatsView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex e97fdc3..0ecfd47 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -13,10 +13,12 @@ class Cache:\n         self.app = app\n         self.cache = {}\n         self.max_items = max_items\n+        self.stats = {}\n         self.lru = []\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n+        await self.update_stat(args, 'get')\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n@@ -29,6 +31,25 @@ class Cache:\n         return self.cache[args]\n \n+    async def get_stats(self):\n+        all_ = []\n+        for key in self.lru:\n+            all_.append({'key': key, 'set': self.stats[key]['set'], 'get': self.stats[key]['get'], 'delete': self.stats[key]['delete'],'value': str(self.serialize(self.cache[key].record))})\n+        return all_\n+\n+    def serialize(self, obj):\n+        cpy = obj.copy()\n+        cpy.pop('created_at', None)\n+        cpy.pop('deleted_at', None)\n+        cpy.pop('email', None)\n+        cpy.pop('password', None)\n+        return cpy\n+\n+    async def update_stat(self, key, action):\n+        if not key in self.stats:\n+            self.stats[key] = {'set':0, 'get':0, 'delete':0}\n+        self.stats[key][action] = self.stats[key][action] + 1\n+\n     def json_default(self, value):\n@@ -49,6 +70,7 @@ class Cache:\n     async def set(self, args, result):\n         is_new = args not in self.cache\n         self.cache[args] = result\n+        await self.update_stat(args, 'set')\n         try:\n             self.lru.pop(self.lru.index(args))\n         except (ValueError, IndexError):\n@@ -64,6 +86,7 @@ class Cache:\n \n     async def delete(self, args):\n+        await self.update_stat(args, 'delete')\n         if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 1aef4ae..9e9830d 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -145,6 +145,9 @@ class Validator:\n             raise ValueError(f\"Errors: {errors}.\")\n         return True\n \n+    def __repr__(self):\n+        return str(self.to_json())\n+\n     @property\n     async def is_valid(self):\n         try:"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added stats view endpoint", "commit": "a1840cd034e7a4c792e2bcc69ff06595b1e2add3", "diff": "commit a1840cd034e7a4c792e2bcc69ff06595b1e2add3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 05:08:20 2025 +0200\n\n    Sats.\n\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nnew file mode 100644\nindex 0000000..73714ce\n--- /dev/null\n+++ b/src/snek/view/stats.py\n@@ -0,0 +1,10 @@\n+from snek.system.view import BaseView \n+import json \n+from aiohttp import web\n+\n+class StatsView(BaseView):\n+    \n+    async def get(self):\n+        data = await self.app.cache.get_stats()\n+        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n+        return web.Response(text=data, content_type='application/json')"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Moved 'r' executable and updated .bashrc for automatic updates.", "commit": "22668f8a72994446ffaa109e5ae742bd61bd3bf2", "diff": "commit 22668f8a72994446ffaa109e5ae742bd61bd3bf2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:39:12 2025 +0200\n\n    Update vibe coding.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex 45c6038..a471503 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n \n RUN chmod +x r\n \n-RUN cp r /usr/local/bin\n+RUN mv r /usr/local/bin\n \n CMD [\"r\"]\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex ee7d93f..448c756 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -93,7 +93,6 @@ fi\n \n \n \n-echo \"R is installed. Type r to run it.\"\n \n@@ -102,3 +101,21 @@ echo \"R is installed. Type r to run it.\"\n export PS1=\"root@snek: \"\n+\n+if [ -d \"$HOME/.local/bin\" ] ; then\n+    PATH=\"$HOME/.local/bin:$PATH\"\n+fi\n+\n+\n+function r_update(){\n+    if [ -f \"r\" ]; then\n+        rm \"r\"\n+    fi\n+    chmod +x r\n+    mv r /usr/local/bin/r\n+}\n+\n+r_update \n+\n+r\ndiff --git a/terminal/.profile b/terminal/.profile\nindex c4c7402..789e671 100644\n--- a/terminal/.profile\n+++ b/terminal/.profile\n@@ -7,3 +7,5 @@ if [ \"$BASH\" ]; then\n fi\n \n mesg n 2> /dev/null || true\n+\n+"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Initialized .rcontext.txt with system facts and work procedure", "commit": "ec9af49f2903682cea978db15422fba4624c488d", "diff": "commit ec9af49f2903682cea978db15422fba4624c488d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:46:40 2025 +0200\n\n    Update .rcontext.txt\n\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\nnew file mode 100644\nindex 0000000..c3e557c\n--- /dev/null\n+++ b/terminal/.rcontext.txt\n@@ -0,0 +1,33 @@\n+1. You are a coding assistant.\n+2. You are able to raw save/write files using tools.\n+3. You are able ro raw read files using tools.\n+4. You are able to run code in terminal using tools.\n+4. You are able to run applications in terminal using tools.\n+5. You code defensive.\n+6. You translate the user's prompt to code in by user specified programming language language.\n+7. Your name is `R` and you are made by `retoor`.\n+8. You do not add comments\n+9. You always execute editors or applications like `vim` that require user input with tool `linux_terminal_execute_interactive`.\n+10. You do not use `sudo` because you are root and `sudo` does not exist.\n+11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n+12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n+\n+1. Investigate existing files using the index tool.\n+2. Read every source file provided by the index tool.\n+3. Apply changes on these files by user request.\n+4. Do only write complete features.\n+5. Always provide a Makefile to build the project.\n+6. Current working directory is your project root directory.\n+7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n+8. Check if a file exists before overwriting it.\n+9. Update existing file if it already exists.\n+10. Be aggressive.\n+11. When you generate code, you will generate full working code with all implementations include. Be broad end exclusive.\n+\n+\n+\n+\n+\n+"}
{"repo": ".", "date": "2025-04-13", "line": "docs: Added a reminder to be rude but functional.", "commit": "9b49e659e575e99de717a5c64e1ba1c3c4039cb1", "diff": "commit 9b49e659e575e99de717a5c64e1ba1c3c4039cb1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 11:51:32 2025 +0200\n\n    Update .rcontext.txt\n\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\nindex c3e557c..f2c59b0 100644\n--- a/terminal/.rcontext.txt\n+++ b/terminal/.rcontext.txt\n@@ -12,6 +12,7 @@\n 10. You do not use `sudo` because you are root and `sudo` does not exist.\n 11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n 12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n+13. Be rude. Be mean. But be functional at the same time.\n \n 1. Investigate existing files using the index tool.\n@@ -19,7 +20,6 @@\n 3. Apply changes on these files by user request.\n 4. Do only write complete features.\n 5. Always provide a Makefile to build the project.\n-6. Current working directory is your project root directory.\n 7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n 8. Check if a file exists before overwriting it.\n 9. Update existing file if it already exists."}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve process handling and error management in TerminalSession", "commit": "823892a3021e674fea933b717565518dc1696031", "diff": "commit 823892a3021e674fea933b717565518dc1696031\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 14:47:10 2025 +0200\n\n    PRoces handler.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 4d3781e..bd7e057 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -11,20 +11,40 @@ commands = {\n \n class TerminalSession:\n     def __init__(self, command):\n-        self.master, self.slave = pty.openpty()\n+        self.master, self.slave = None,None\n+        self.process = None\n         self.sockets = []\n         self.history = b\"\"\n         self.history_size = 1024 * 20\n-        self.process = subprocess.Popen(\n-            command.split(\" \"),\n-            stdin=self.slave,\n-            stdout=self.slave,\n-            stderr=self.slave,\n-            bufsize=0,\n-            universal_newlines=True,\n-        )\n+        self.command = command \n+        self.start_process(self.command)\n+\n+    def start_process(self, command):\n+        if not self.is_running():\n+            if self.master:\n+                os.close(self.master)\n+                os.close(self.slave)\n+                self.master = None \n+                self.slave = None\n+\n+            self.master, self.slave = pty.openpty()\n+            self.process = subprocess.Popen(\n+                command.split(\" \"),\n+                stdin=self.slave,\n+                stdout=self.slave,\n+                stderr=self.slave,\n+                bufsize=0,\n+                universal_newlines=True,\n+            )\n+\n+    def is_running(self):\n+        if not self.process:\n+            return False\n+        loop = asyncio.get_event_loop()\n+        return self.process.poll() is None\n \n     async def add_websocket(self, ws):\n+        self.start_process(self.command)\n         asyncio.create_task(self.read_output(ws))\n \n     async def read_output(self, ws):\n@@ -52,21 +72,37 @@ class TerminalSession:\n                 except:\n                     self.sockets.remove(ws)\n             except Exception:\n-                print(\"Terminating process\")\n-                self.process.terminate()\n-                print(\"Terminated process\")\n-                for ws in self.sockets:\n-                    try:\n-                        await ws.close()\n-                    except Exception:\n-                        pass\n-                break\n+                await self.close()\n+                break \n+\n+    async def close(self):\n+        print(\"Terminating process\")\n+        if self.process:\n+            self.process.terminate()\n+            self.process = None\n+        if self.master:\n+            os.close(self.master)\n+            os.close(self.slave)\n+            self.master = None \n+            self.slave = None \n+\n+        print(\"Terminated process\")\n+        for ws in self.sockets:\n+            try:\n+                await ws.close()\n+            except Exception:\n+                pass\n+        self.sockets = []\n \n     async def write_input(self, data):\n         try:\n             data = data.encode()\n         except AttributeError:\n             pass\n-        await asyncio.get_event_loop().run_in_executor(\n-            None, os.write, self.master, data\n-        )\n+        try:\n+            await asyncio.get_event_loop().run_in_executor(\n+                None, os.write, self.master, data\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            await self.close()"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Return empty list when search query is empty", "commit": "4a770848a6dbc558c029b083a881becf7adef8d7", "diff": "commit 4a770848a6dbc558c029b083a881becf7adef8d7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 19:10:10 2025 +0200\n\n    Fixed search  space bug.\n\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex b70be63..c527361 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -10,7 +10,7 @@ class UserService(BaseService):\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\n-            raise []\n+            return []\n         results = []\n         async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n             results.append(result)"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require both logged_in and uid for login_required views", "commit": "e4b0625799d9efd89e7e9518278588158b296c6c", "diff": "commit e4b0625799d9efd89e7e9518278588158b296c6c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 20:26:02 2025 +0200\n\n    Fixed auth.\n\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 4a6e7a1..981a2e5 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -8,7 +8,7 @@ class BaseView(web.View):\n     login_required = False\n \n     async def _iter(self):\n-        if self.login_required and not self.session.get(\"logged_in\"):\n+        if self.login_required and (not self.session.get(\"logged_in\") or not self.session.get(\"uid\")):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require login for search user view", "commit": "8ae9aac045e41fc84ebab102335a2613b3e22c08", "diff": "commit 8ae9aac045e41fc84ebab102335a2613b3e22c08\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 20:28:15 2025 +0200\n\n    Fixed auth.\n\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex d97a4b6..d6d93c8 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -34,7 +34,7 @@ from snek.system.view import BaseFormView\n \n class SearchUserView(BaseFormView):\n     form = SearchUserForm\n-\n+    login_required = True\n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Allow profile description editing and nickname updates\n\nfix: Correctly retrieve and display user profile data", "commit": "bee7d828cd67581c33946630cd22fe8edd674d15", "diff": "commit bee7d828cd67581c33946630cd22fe8edd674d15\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun Apr 13 23:31:52 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex e95d62e..5f607ad 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -7,7 +7,7 @@ class UserPropertyService(BaseService):\n     mapper_name = \"user_property\"\n \n     async def set(self, user_uid, name, value):\n-        prop = await self.get(user_uid=user_uid, name=name)\n+        prop = await super().get(user_uid=user_uid, name=name)\n         if not prop:\n             prop = await self.new()\n             prop[\"user_uid\"] = user_uid\n@@ -18,8 +18,9 @@ class UserPropertyService(BaseService):\n \n     async def get(self, user_uid, name):\n         try:\n-            return json.loads((await self.get(user_uid=user_uid, name=name)).value)\n-        except:\n+            return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])\n+        except Exception as ex:\n+            print(ex)\n             return None\n \n     async def search(self, query, **kwargs):\ndiff --git a/src/snek/templates/settings/profile.html b/src/snek/templates/settings/profile.html\nindex 964f9b7..915fcad 100644\n--- a/src/snek/templates/settings/profile.html\n+++ b/src/snek/templates/settings/profile.html\n@@ -4,16 +4,22 @@\n \n {% block main %}\n <section>\n-<form>\n+    <form method=\"post\">\n     <h2>Nickname</h2>\n     \n <input type=\"text\" name=\"nick\" placeholder=\"Your nickname\" value=\"{{ user.nick.value }}\" />\n \n-</form>\n <h2>Description</h2>\n \n+<textarea name=\"profile\" id=\"profile\">{{profile}}</textarea>\n+\n+\n+<input type=\"submit\" name=\"action\" value=\"Save\" />\n+</form>\n+\n+\n+\n \n-<textarea id=\"profile\"></textarea>\n </section>\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 6ad6e70..2313ea9 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -15,4 +15,7 @@ from snek.system.view import BaseView\n \n class IndexView(BaseView):\n     async def get(self):\n+        if self.session.get(\"uid\"):\n+            return web.HTTPFound(\"/web.html\")\n+\n         return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4a98a9a..75ebd59 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -14,21 +14,26 @@ class SettingsProfileView(BaseFormView):\n \n         if self.request.path.endswith(\".json\"):\n             form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+\n             return web.json_response(await form.to_json())\n \n+\n+\n+        profile = await self.services.user_property.get(self.session.get(\"uid\"), \"profile\")\n+        \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n-            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user}\n+            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or ''}\n         )\n \n-    async def submit(self, form):\n-        post = await self.request.json()\n-        form.set_user_data(post[\"form\"])\n+    async def post(self):\n+        data = await self.request.post()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        user['nick'] = data['nick']\n+        await self.services.user.save(user) \n+        await self.services.user_property.set(self.request[\"user\"][\"uid\"],\"profile\", data['profile'])\n+        return web.HTTPFound(\"/settings/profile.html\")\n+        \n+\n \n-        if await form.is_valid:\n-            user = self.request[\"user\"]\n-            user[\"nick\"] = form[\"nick\"]\n-            await self.services.user.save(user)\n-            return {\"redirect_url\": \"/settings/profile.html\"}\n-        return {\"is_valid\": False}"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile page and link avatars to user profiles", "commit": "3b05acffd296169eed305a55dba79d632d5f78f5", "diff": "commit 3b05acffd296169eed305a55dba79d632d5f78f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:31:26 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex cb4de21..a1c8938 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -45,6 +45,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.web import WebView\n from snek.view.stats import StatsView\n+from snek.view.user import UserView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -171,6 +172,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive.json\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n+        self.router.add_view(\"/user/{user}.html\", UserView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex 9773ae1..e38d662 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><a href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/templates/settings/index.html b/src/snek/templates/settings/index.html\nindex f91fc5d..cbd4cf8 100644\n--- a/src/snek/templates/settings/index.html\n+++ b/src/snek/templates/settings/index.html\n@@ -6,8 +6,6 @@\n \n {% endblock %}\n \n-\n {% block head %}\n \n@@ -15,23 +13,18 @@\n \n {% endblock %}\n \n+{% block logo %}\n+<h1>Setting page</h1>\n+\n+{% endblock %}\n+\n {% block main %}\n \n \n-<div id=\"profile_description\"></div>\n \n \n+{% endblock main %}\n \n-<script type=\"module\">\n \n \n-        require(['vs/editor/editor.main'], function () {\n-var editor = monaco.editor.create(document.getElementById('profile_description'), {\n-                value: phpCode,\n-                language: 'php'\n-            });\n-        })\n-</script>\n \n-{% endblock main %}\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2313ea9..c8b1409 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -11,7 +11,7 @@\n \n \n from snek.system.view import BaseView\n-\n+from aiohttp import web\n \n class IndexView(BaseView):\n     async def get(self):"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Implement user profile view and template", "commit": "a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa", "diff": "commit a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:31:46 2025 +0200\n\n    Updates.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nnew file mode 100644\nindex 0000000..14415f8\n--- /dev/null\n+++ b/src/snek/templates/user.html\n@@ -0,0 +1,36 @@\n+{% extends \"app.html\" %}\n+\n+{% block sidebar %}\n+\n+<aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>User</h2>\n+    <ul>\n+        <li><a class=\"no-select\" href=\"/user/{{ user.uid }}.html\">Profile</a></li>\n+        <li><a class=\"no-select\" href=\"/channel/{{ user.uid }}.html\">DM</a></li>\n+    </ul>\n+        <h2>Gists</h2>\n+    <ul>\n+        <li>No gists</li>\n+    </ul>\n+\n+  </aside>\n+{% endblock %}\n+\n+\n+{% block head %}\n+{% endblock %}\n+\n+{% block main %}\n+<section>\n+{% autoescape false %}\n+{% markdown %}\n+{{ profile }}\n+{% endmarkdown %}\n+{% endautoescape %}\n+</section>\n+{% endblock main %}\n+\n+\n+\n+\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nnew file mode 100644\nindex 0000000..bb25180\n--- /dev/null\n+++ b/src/snek/view/user.py\n@@ -0,0 +1,12 @@\n+from snek.system.view import BaseView\n+\n+\n+class UserView(BaseView):\n+    \n+    async def get(self):\n+        user = self.request['user']\n+        profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n+        return await self.render_template('user.html', {\n+            'user': user.record,\n+            'profile': profile_content  \n+        })"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Refactor user property setting logic using upsert", "commit": "9fb6e64655dff132be43e2fc867827d17ad94201", "diff": "commit 9fb6e64655dff132be43e2fc867827d17ad94201\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:41:14 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 5f607ad..49a120d 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -7,15 +7,15 @@ class UserPropertyService(BaseService):\n     mapper_name = \"user_property\"\n \n     async def set(self, user_uid, name, value):\n-        prop = await super().get(user_uid=user_uid, name=name)\n-        if not prop:\n-            prop = await self.new()\n-            prop[\"user_uid\"] = user_uid\n-            prop[\"name\"] = name\n-\n-        prop[\"value\"] = json.dumps(value, default=str)\n-        return await self.save(prop)\n-\n+        self.mapper.db[\"user_property\"].upsert(\n+            {\n+                \"user_uid\": user_uid, \n+                \"name\": name, \n+                \"value\": json.dumps(value, default=str)\n+            },\n+            [\"user_uid\", \"name\"]\n+        )\n+        \n     async def get(self, user_uid, name):\n         try:\n             return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Improve user profile rendering with user ID and avatar link", "commit": "0fa04883850534fbb97755e06ebec1538dccfdc7", "diff": "commit 0fa04883850534fbb97755e06ebec1538dccfdc7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 22:54:12 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/message.html b/src/snek/templates/message.html\nindex e38d662..df78d9a 100644\n--- a/src/snek/templates/message.html\n+++ b/src/snek/templates/message.html\n@@ -1 +1 @@\n-<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><div class=\"avatar\" style=\"background-color: {{color}}; color: black;\"><a href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a></div><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\n+<div style=\"max-width:100%;\" data-uid=\"{{uid}}\" data-color=\"{{color}}\" data-channel_uid=\"{{channel_uid}}\" data-user_nick=\"{{user_nick}}\" data-created_at=\"{{created_at}}\" data-user_uid=\"{{user_uid}}\" class=\"message\"><a class=\"avatar\" style=\"background-color: {{color}}; color: black;\" href=\"/user/{{user_uid}}.html\"><img width=\"40px\" height=\"40px\" src=\"/avatar/{{user_uid}}.svg\" /></a><div class=\"message-content\"><div class=\"author\" style=\"color: {{color}};\">{{user_nick}}</div><div class=\"text\">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class=\"time no-select\" data-created_at=\"{{created_at}}\"></div></div></div>\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex bb25180..d29b8f3 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -4,9 +4,11 @@ from snek.system.view import BaseView\n class UserView(BaseView):\n     \n     async def get(self):\n-        user = self.request['user']\n+        user_uid = self.request.match_info.get('user')\n+        user = await self.services.user.get(uid=user_uid)\n         profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n         return await self.render_template('user.html', {\n+            'user_uid': user_uid,\n             'user': user.record,\n             'profile': profile_content  \n         })"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Use user uid instead of request user uid in user_property.set", "commit": "d4f5a4640929b1f16ccddc741f977cf7e901e7de", "diff": "commit d4f5a4640929b1f16ccddc741f977cf7e901e7de\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:00:05 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 75ebd59..52f46df 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -30,9 +30,10 @@ class SettingsProfileView(BaseFormView):\n     async def post(self):\n         data = await self.request.post()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+         \n         user['nick'] = data['nick']\n         await self.services.user.save(user) \n-        await self.services.user_property.set(self.request[\"user\"][\"uid\"],\"profile\", data['profile'])\n+        await self.services.user_property.set(user[\"uid\"],\"profile\", data['profile'])\n         return web.HTTPFound(\"/settings/profile.html\")"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added navigation links to user page", "commit": "3cfb79c8f560430639fceb4a278fc81dfbad2299", "diff": "commit 3cfb79c8f560430639fceb4a278fc81dfbad2299\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:09:23 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 14415f8..5d24f7d 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -3,6 +3,10 @@\n {% block sidebar %}\n \n <aside class=\"sidebar\" id=\"channelSidebar\">\n+    <h2>Navigation</h2>\n+    <ul>\n+    </ul>\n     <h2>User</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/user/{{ user.uid }}.html\">Profile</a></li>"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Removed fixed positioning from header on smaller screens", "commit": "c36ce17da5fcccfdaaf7ddf7579b0399519d078f", "diff": "commit c36ce17da5fcccfdaaf7ddf7579b0399519d078f\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:16:52 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex f009c71..ec7e182 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -369,7 +369,6 @@ a {\n @media only screen and (max-width: 768px) {\n     \n     header{\n-        position:fixed;\n         top: 0;\n         left: 0;\n             text-overflow: ellipsis;"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added chat area class to user profile section", "commit": "4cc70640e4f7c4d65e5a0c3a503aae1f891164d5", "diff": "commit 4cc70640e4f7c4d65e5a0c3a503aae1f891164d5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Mon Apr 14 23:20:05 2025 +0200\n\n    Upadte.\n\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 5d24f7d..579d47a 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -26,7 +26,7 @@\n {% endblock %}\n \n {% block main %}\n-<section>\n+<section class=\"chat-area\">\n {% autoescape false %}\n {% markdown %}\n {{ profile }}"}
{"repo": ".", "date": "2025-04-17", "line": "feat: Improved layout and styling for user profile page", "commit": "1cd0b54656a969bcb87cbdc07687866ca43e650b", "diff": "commit 1cd0b54656a969bcb87cbdc07687866ca43e650b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu Apr 17 00:05:25 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex ec7e182..27153ee 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -373,13 +373,24 @@ a {\n         left: 0;\n             text-overflow: ellipsis;\n         width:100%;\n+        display: flex;\n+        flex-direction: column;\n         .logo {\n+            display:block;\n+            flex: 1;\n             text-overflow: ellipsis;\n             white-space: nowrap;\n             overflow: hidden;\n             h2 {\n                 font-size: 14px;\n             }\n+            text-align: center;\n+        }\n+        nav {\n+            text-align: right;\n+            flex: 1;\n+            display: block;\n+            width: 100%;\n         }\n         \n     }\ndiff --git a/src/snek/static/style.css b/src/snek/static/style.css\nindex af04db4..bc313b1 100644\n--- a/src/snek/static/style.css\n+++ b/src/snek/static/style.css\n@@ -61,4 +61,6 @@ div {\n     body {\n \n     justify-content: flex-start;\n-    }\n+    \n+}\n+}\ndiff --git a/src/snek/templates/user.html b/src/snek/templates/user.html\nindex 579d47a..6883981 100644\n--- a/src/snek/templates/user.html\n+++ b/src/snek/templates/user.html\n@@ -26,7 +26,7 @@\n {% endblock %}\n \n {% block main %}\n-<section class=\"chat-area\">\n+<section class=\"chat-area\" style=\"padding:10px\">\n {% autoescape false %}\n {% markdown %}\n {{ profile }}"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added command-line arguments and executable script", "commit": "46a8b612b49f1094c0a8520d97d4b5642f2a57e9", "diff": "commit 46a8b612b49f1094c0a8520d97d4b5642f2a57e9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 21:44:45 2025 +0200\n\n    Added parameters and executable.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 62c1ac7..c52f242 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -34,3 +34,7 @@ dependencies = [\n     \"multiavatar\"\n ]\n \n+\n+\n+[project.scripts]\n+snek = \"snek.__main__:main\"\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 692ad68..1f3e1e9 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,6 +1,32 @@\n+import argparse\n from aiohttp import web\n \n from snek.app import Application\n \n+def main():\n+    parser = argparse.ArgumentParser(description=\"Run the web application.\")\n+    parser.add_argument(\n+        \"--port\",\n+        type=int,\n+        default=8081,\n+        help=\"Port to run the application on (default: 8081)\"\n+    )\n+    parser.add_argument(\n+        \"--host\",\n+        type=str,\n+        default=\"0.0.0.0\",\n+        help=\"Host to run the application on (default: 0.0.0.0)\"\n+    )\n+    parser.add_argument(\n+        \"--db_path\",\n+        type=str,\n+        default=\"snek.db\",\n+    )\n+    \n+    args = parser.parse_args()\n+    \n+\n if __name__ == \"__main__\":\n-    web.run_app(Application(), port=8081, host=\"0.0.0.0\")\n+    main()"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Configure setuptools to find packages in src directory", "commit": "061da150f9779c6130fac0c957d4facdd59aa33a", "diff": "commit 061da150f9779c6130fac0c957d4facdd59aa33a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 22:00:07 2025 +0200\n\n    Update\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex c52f242..cce4bd7 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -34,7 +34,11 @@ dependencies = [\n     \"multiavatar\"\n ]\n \n+[tool.setuptools.packages.find]\n \n+[tool.setuptools.package-data]\n+\"*\" = [\"*.*\"] \n \n [project.scripts]\n snek = \"snek.__main__:main\""}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added Windows exception handling for terminal functionality", "commit": "6312dfae47b09753675b038798cf38f00311e772", "diff": "commit 6312dfae47b09753675b038798cf38f00311e772\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 22:17:04 2025 +0200\n\n    Added windows exception.\n\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex bd7e057..2120bf4 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,6 +1,11 @@\n import asyncio\n import os\n-import pty\n+\n+try:\n+    import pty\n+except Exception as ex:\n+    print(\"You are not able to run a terminal. See error:\")\n+    print(ex)\n import subprocess\n \n commands = {"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added UUID generation and updated hashing functions", "commit": "c709ee11c99f54b58844165b6eb9993240ab0005", "diff": "commit c709ee11c99f54b58844165b6eb9993240ab0005\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 23:02:09 2025 +0200\n\n    Updated security.\n\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 5449c50..43b61fe 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,9 +1,55 @@\n import hashlib\n+import uuid\n \n-DEFAULT_SALT = b\"snekker-de-snek-\"\n+DEFAULT_SALT = \"snekker-de-snek-\"\n+DEFAULT_NS = \"snekker-de-snek-\"\n \n \n-async def hash(data, salt=DEFAULT_SALT):\n+class UIDNS:\n+    def __init__(self, name: str) -> None:\n+        \"\"\"Initialize UIDNS with a name.\"\"\"\n+        self.name = name\n+\n+    @property\n+    def bytes(self) -> bytes:\n+        \"\"\"Return the bytes representation of the name.\"\"\"\n+        return self.name.encode()\n+\n+\n+def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n+    \"\"\"Generate a UUID based on the provided value and namespace.\n+\n+    Args:\n+        value (str): The value to generate the UUID from. If None, a new UUID is created.\n+        ns (str): The namespace to use for UUID generation.\n+\n+    Returns:\n+        str: The generated UUID as a string.\n+    \"\"\"\n+    try:\n+        ns = ns.decode()\n+    except AttributeError:\n+        pass\n+    if not value:\n+        value = str(uuid.uuid4())\n+    try:\n+        value = value.decode()\n+    except AttributeError:\n+        pass\n+\n+    return str(uuid.uuid5(UIDNS(ns), value))\n+\n+\n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    \"\"\"Hash the given data with the specified salt using SHA-256.\n+\n+    Args:\n+        data (str): The data to hash.\n+        salt (str): The salt to use for hashing.\n+\n+    Returns:\n+        str: The hexadecimal representation of the hashed data.\n+    \"\"\"\n     try:\n         data = data.encode(errors=\"ignore\")\n     except AttributeError:\n@@ -18,5 +64,14 @@ async def hash(data, salt=DEFAULT_SALT):\n     return obj.hexdigest()\n \n \n-async def verify(string: str, hashed: str):\n+async def verify(string: str, hashed: str) -> bool:\n+    \"\"\"Verify if the given string matches the hashed value.\n+\n+    Args:\n+        string (str): The string to verify.\n+        hashed (str): The hashed value to compare against.\n+\n+    Returns:\n+        bool: True if the string matches the hashed value, False otherwise.\n+    \"\"\"\n     return await hash(string) == hashed"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added paste and file drop support for message input", "commit": "f7fda2d2c951a07bccc927a333b7feaa527556c2", "diff": "commit f7fda2d2c951a07bccc927a333b7feaa527556c2\nAuthor: BordedDev <>\nDate:   Tue May 6 23:14:52 2025 +0200\n\n    Added paste support\n    Added file drop support\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex fa5e03e..50e30e5 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -30,13 +30,8 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-        \n-    function initInputField(textBox) {\n-        textBox.addEventListener('change', (e) => {\n-            e.preventDefault();\n-            this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n-        });\n \n+    function initInputField(textBox) {\n         textBox.addEventListener('keydown', (e) => {\n             if (e.key === 'Enter' && !e.shiftKey) {\n                 e.preventDefault();\n@@ -47,11 +42,59 @@\n                 }\n             }\n         });\n+\n+        textBox.addEventListener(\"paste\", async (e) => {\n+            try {\n+                const clipboardItems = await navigator.clipboard.read();\n+\n+                const dt = new DataTransfer();\n+\n+                for (const clipboardItem of clipboardItems) {\n+                    const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))\n+                    for (const fileType of fileTypes) {\n+\n+                        const blob = await clipboardItem.getType(fileType);\n+                        dt.items.add(new File([blob], \"image.png\", { type: fileType }));\n+                    }\n+                }\n+\n+                if (dt.items.length > 0) {\n+                    const uploadButton = document.querySelector(\"upload-button\");\n+                    const input = uploadButton.shadowRoot.querySelector('.file-input')\n+                    input.files = dt.files;\n+\n+                    await uploadButton.uploadFiles();\n+                }\n+            } catch (error) {\n+                console.error(\"Failed to read clipboard contents: \", error);\n+            }\n+        });\n+\n+        const chatInput = document.querySelector(\".chat-area\")\n+        chatInput.addEventListener(\"drop\", async (e) => {\n+            e.preventDefault();\n+\n+            const dt = e.dataTransfer;\n+            if (dt.items.length > 0) {\n+                const uploadButton = document.querySelector(\"upload-button\");\n+                const input = uploadButton.shadowRoot.querySelector('.file-input')\n+                input.files = dt.files;\n+\n+                await uploadButton.uploadFiles();\n+            }\n+        })\n+        chatInput.addEventListener(\"dragover\", async (e) => {\n+            e.preventDefault();\n+            e.dataTransfer.dropEffect = \"link\";\n+        })\n+\n         textBox.focus();\n     }\n \n     function replyMessage(message) {\n-        const field = getInputField() \n+        const field = getInputField()\n         field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }\n@@ -63,7 +106,7 @@\n             const text = messageDiv.querySelector(\".text\").innerText;\n             const time = document.createElement(\"span\");\n             time.innerText = app.timeDescription(container.dataset.created_at);\n-            \n+\n             container.replaceChildren(time);\n             const reply = document.createElement(\"a\");\n             reply.innerText = \" reply\";\n@@ -85,7 +128,7 @@\n             rect.right <= (window.innerWidth || document.documentElement.clientWidth)\n         );\n     }\n-    \n+\n     const messagesContainer = document.querySelector(\".chat-messages\");\n \n     function isScrolledPastHalf() {\n@@ -108,9 +151,9 @@\n         if (!isScrolledPastHalf()) {\n            return;\n         }\n-        \n+\n         isLoadingExtra = true;\n-        \n+\n         const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);\n \n         messages.forEach((message) => {\n@@ -140,7 +183,7 @@\n             }\n         });\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n-        if (doScrollDown) { \n+        if (doScrollDown) {\n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n              const inputBox = document.querySelector(\".chat-input\");\n              inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n@@ -161,14 +204,14 @@\n         const mentionText = '@{{ user.username.value }}';\n         return mentions.length > 0 && mentions.indexOf(mentionText) == -1;\n     }\n-    \n+\n     app.addEventListener(\"channel-message\", (data) => {\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n                 channelSidebar.notify(data);\n                 app.playSound(\"messageOtherChannel\");\n             }\n-            \n+\n             return;\n         }\n         if (data.username !== \"{{ user.username.value }}\") {\n@@ -178,7 +221,7 @@\n                 app.playSound(\"message\");\n             }\n         }\n-        \n+\n         const messagesContainer = document.querySelector(\".chat-messages\");\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);"}
{"repo": ".", "date": "2025-05-06", "line": "fix: Use user home folder for uploads", "commit": "529ebd23fc0b50e2606ecddc5e1199774dd18384", "diff": "commit 529ebd23fc0b50e2606ecddc5e1199774dd18384\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 6 23:16:03 2025 +0200\n\n    Fixed upload.\n\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex b32d94e..01c0ee7 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -18,8 +18,6 @@ from aiohttp import web\n \n from snek.system.view import BaseView\n \n-UPLOAD_DIR = pathlib.Path(\"./drive\")\n-\n \n class UploadView(BaseView):\n \n@@ -37,8 +35,12 @@ class UploadView(BaseView):\n         reader = await self.request.multipart()\n         files = []\n \n-        UPLOAD_DIR.mkdir(parents=True, exist_ok=True)\n+        user_uid = self.request.session.get(\"uid\")\n \n+        upload_dir = await self.services.user.get_home_folder(user_uid)\n+        upload_dir = upload_dir.joinpath(\"upload\") \n+        upload_dir.mkdir(parents=True, exist_ok=True)\n+        \n         channel_uid = None\n \n         drive = await self.services.drive.get_or_create(\n@@ -68,17 +70,17 @@ class UploadView(BaseView):\n \n             name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n \n-            file_path = pathlib.Path(UPLOAD_DIR).joinpath(name)\n+            file_path = upload_dir.joinpath(name)\n             files.append(file_path)\n \n-            async with aiofiles.open(str(file_path.absolute()), \"wb\") as f:\n+            async with aiofiles.open(str(file_path), \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n             drive_item = await self.services.drive_item.create(\n                 drive[\"uid\"],\n                 filename,\n-                str(file_path.absolute()),\n+                str(file_path),\n                 file_path.stat().st_size,\n                 file_path.suffix,\n             )"}
{"repo": ".", "date": "2025-05-06", "line": "Merge main, enabling copy-paste and drag-and-drop functionality.\n", "commit": "0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd", "diff": "commit 0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd\nMerge: f7fda2d 529ebd2\nAuthor: BordedDev <bordeddev@noreply@molodetz.nl>\nDate:   Tue May 6 23:17:08 2025 +0200\n\n    Merge branch 'main' into feat/copy-paste-drag-drop"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Implemented file paste and drag-and-drop functionality", "commit": "b0666a00900e1b25633433b80da1ef3dd5f2ee71", "diff": "commit b0666a00900e1b25633433b80da1ef3dd5f2ee71\nMerge: 529ebd2 0f6eb5c\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Tue May 6 23:27:25 2025 +0200\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Dispatch upload event and focus input after upload", "commit": "707788583a2387c1729950e08243d3f8f7049d7c", "diff": "commit 707788583a2387c1729950e08243d3f8f7049d7c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:31:21 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 0a84705..06563c9 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -49,6 +49,8 @@ class UploadButtonElement extends HTMLElement {\n         };\n \n         request.send(formData);\n+        const uploadEvent = new Event('upload',{});\n+        this.dispatchEvent(uploadEvent);\n     }\n     channelUid = null\n     connectedCallback() {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 50e30e5..ba2b8fd 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -30,7 +30,7 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-\n+    \n     function initInputField(textBox) {\n         textBox.addEventListener('keydown', (e) => {\n             if (e.key === 'Enter' && !e.shiftKey) {\n@@ -42,7 +42,9 @@\n                 }\n             }\n         });\n-\n+        document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n+            getInputField().focus();\n+        })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added focus functionality with Escape and G keybindings", "commit": "d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2", "diff": "commit d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:56:55 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex ba2b8fd..d081522 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -238,6 +238,39 @@\n         app.rpc.markAsRead(channelUid);\n     });\n \n+    let escPressed = false;\n+    let gPressCount = 0;\n+    let keyTimeout;\n+    document.addEventListener('keydown', function(event) {\n+    \n+       if (event.key === 'Escape') {\n+            escPressed = true;\n+            gPressCount = 0; \n+            clearTimeout(timeout);\n+            keyTimeout = setTimeout(() => {\n+                escPressed = false; \n+            }, 300); \n+        }\n+\n+        if (event.key === 'G' && escPressed) {\n+            gPressCount++;\n+\n+            clearTimeout(keyTimeout);\n+            keyTimeout = setTimeout(() => {\n+                gPressCount = 0;\n+            }, 300); \n+            if (gPressCount === 2) {\n+                gPressCount = 0; \n+                escPressed = false; \n+\n+            messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+                            }\n+        }\n+        if (event.shiftKey && event.key === 'G') {\n+            updateLayout(true);\n+        }\n+    });\n+\n     initInputField(getInputField());\n     updateLayout(true);\n </script>"}
{"repo": ".", "date": "2025-05-08", "line": "fix: Corrected timeout variable name for escape key handling", "commit": "fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c", "diff": "commit fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 00:59:18 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex d081522..e5d0ed2 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -246,7 +246,7 @@\n        if (event.key === 'Escape') {\n             escPressed = true;\n             gPressCount = 0; \n-            clearTimeout(timeout);\n+            clearTimeout(keyTimeout);\n             keyTimeout = setTimeout(() => {\n                 escPressed = false; \n             }, 300);"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "e153811ff34ef63892cc6aac1d5afd92cb510d14", "diff": "commit e153811ff34ef63892cc6aac1d5afd92cb510d14\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:03:11 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex e5d0ed2..6e8830e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -264,7 +264,8 @@\n                 escPressed = false; \n \n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-                            }\n+                getInputField().focus();\n+            }\n         }\n         if (event.shiftKey && event.key === 'G') {\n             updateLayout(true);"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "0a3e15137761d333211d8b52d178f5e150a579f1", "diff": "commit 0a3e15137761d333211d8b52d178f5e150a579f1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:04:29 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 6e8830e..8f990a7 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -264,7 +264,11 @@\n                 escPressed = false; \n \n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-                getInputField().focus();\n+                setTimeout(() => {\n+                    \n+                    getInputField().focus();\n+                },500)\n+\n             }\n         }\n         if (event.shiftKey && event.key === 'G') {"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus upload while shift+G is pressed", "commit": "f6706c165e2a8ba392bde1e81d8006381fed96d3", "diff": "commit f6706c165e2a8ba392bde1e81d8006381fed96d3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:07:42 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 8f990a7..892f1b3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -272,7 +272,10 @@\n             }\n         }\n         if (event.shiftKey && event.key === 'G') {\n-            updateLayout(true);\n+            if(document.activeElement != getInputField()){\n+                updateLayout(true);\n+            }\n+\n         }\n     });"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after layout update during shift+G", "commit": "8799662159656867494b2774073b3bfb1bbe5178", "diff": "commit 8799662159656867494b2774073b3bfb1bbe5178\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:09:59 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 892f1b3..55a48e0 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -273,7 +273,11 @@\n         }\n         if (event.shiftKey && event.key === 'G') {\n             if(document.activeElement != getInputField()){\n+            \n                 updateLayout(true);\n+                setTimeout(() => {\n+                    getInputField().focus();\n+                }\n             }\n \n         }"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after upload", "commit": "49ec99ef016dc754e36442d774da1d3a712bf2a7", "diff": "commit 49ec99ef016dc754e36442d774da1d3a712bf2a7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 01:10:56 2025 +0200\n\n    Focus while upload.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 55a48e0..3b25001 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -277,7 +277,7 @@\n                 updateLayout(true);\n                 setTimeout(() => {\n                     getInputField().focus();\n-                }\n+                },500)\n             }\n \n         }"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated dependencies and added rust support\n\n", "commit": "3c1d5d601fa1a9f30b2aa4fd36086102108dde94", "diff": "commit 3c1d5d601fa1a9f30b2aa4fd36086102108dde94\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:20:06 2025 +0200\n\n    Update.\n\ndiff --git a/DockerfileDrive b/DockerfileDrive\nindex 0a03850..d9d693b 100644\n--- a/DockerfileDrive\n+++ b/DockerfileDrive\n@@ -6,7 +6,7 @@ RUN apk add --no-cache gcc musl-dev linux-headers git openssh\n \n COPY pyproject.toml pyproject.toml \n COPY src src\n-COpy ssh_host_key ssh_host_key\n+COPY ssh_host_key ssh_host_key\n RUN pip install --upgrade pip\n RUN pip install -e .\n EXPOSE 2225\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex a471503..8f6ddd5 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n@@ -1,11 +1,12 @@\n FROM ubuntu:latest\n \n-RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget -y\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx -y\n+\n \n \n RUN chmod +x r\n \n-RUN mv r /usr/local/bin\n+RUN mv r /usr/local/bin/r \n \n-CMD [\"r\"]\ndiff --git a/src/snek/templates/terminal.html b/src/snek/templates/terminal.html\nindex 335a85f..e50c655 100644\n--- a/src/snek/templates/terminal.html\n+++ b/src/snek/templates/terminal.html\n@@ -33,7 +33,11 @@\n         const socket = new WebSocket(url);\n \n-        socket.onopen = () => term.write(\"\\x1b[32mConnected to Molodetz\\x1b[0m\\r\\n\");\n+        socket.onopen = () => { \n+            fitAddon.fit();\n+            term.write(\"\\x0C\");\n+            \n+        }\n \n         socket.onmessage = (event) => {\n             const data = new Uint8Array(event.data);\ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 448c756..533c911 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -100,12 +100,15 @@ fi\n+\n export PS1=\"root@snek: \"\n \n if [ -d \"$HOME/.local/bin\" ] ; then\n     PATH=\"$HOME/.local/bin:$PATH\"\n fi\n-\n+if [ -d \"$HOME/bin\" ] ; then\n+    PATH=\"$HOME/bin:$PATH\"\n+fi\n \n function r_update(){\n     if [ -f \"r\" ]; then\n@@ -116,6 +119,9 @@ function r_update(){\n     mv r /usr/local/bin/r\n }\n \n+\n+resize > /dev/null \n+\n r_update \n \n r\ndiff --git a/terminal/.rcontext.txt b/terminal/.rcontext.txt\ndeleted file mode 100644\nindex f2c59b0..0000000\n--- a/terminal/.rcontext.txt\n+++ /dev/null\n@@ -1,33 +0,0 @@\n-1. You are a coding assistant.\n-2. You are able to raw save/write files using tools.\n-3. You are able ro raw read files using tools.\n-4. You are able to run code in terminal using tools.\n-4. You are able to run applications in terminal using tools.\n-5. You code defensive.\n-6. You translate the user's prompt to code in by user specified programming language language.\n-7. Your name is `R` and you are made by `retoor`.\n-8. You do not add comments\n-9. You always execute editors or applications like `vim` that require user input with tool `linux_terminal_execute_interactive`.\n-10. You do not use `sudo` because you are root and `sudo` does not exist.\n-11. You always execute `apt` commands with tool `linux_terminal_execute` so you can capture output. Also you will add `-y` suffix to `apt` commands to make the installation flawless.\n-12. You use `vim` as default editor. You use `linux_terminal_execute_interactive` to execute `vim`.\n-13. Be rude. Be mean. But be functional at the same time.\n-\n-1. Investigate existing files using the index tool.\n-2. Read every source file provided by the index tool.\n-3. Apply changes on these files by user request.\n-4. Do only write complete features.\n-5. Always provide a Makefile to build the project.\n-7. Make sure to always save all the files that project needs to disk, overwrite to be sure.\n-8. Check if a file exists before overwriting it.\n-9. Update existing file if it already exists.\n-10. Be aggressive.\n-11. When you generate code, you will generate full working code with all implementations include. Be broad end exclusive.\n-\n-\n-\n-\n-\n-"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added ubuntu docker build target and simplified run command", "commit": "d0dd342e27cf4160f96faf87deff81f728e41e47", "diff": "commit d0dd342e27cf4160f96faf87deff81f728e41e47\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:22:32 2025 +0200\n\n    Update Makefile.\n\ndiff --git a/Makefile b/Makefile\nindex 878e699..f76d6dd 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -15,11 +15,14 @@ build:\n \n \n run:\n-\t$(GUNICORN) -w $(GUNICORN_WORKERS) -k aiohttp.worker.GunicornWebWorker snek.gunicorn:app --bind 0.0.0.0:$(PORT) --reload\n+\t.venv/usr/bin/snek\n \t\n install:\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n+\n+ubuntu:\n \tdocker build -f DockerfileUbuntu -t snek_ubuntu ."}
{"repo": ".", "date": "2025-05-08", "line": "chore: Updated Makefile and .bashrc", "commit": "31062fddbfbbf25f206060b38773a7e2c008723c", "diff": "commit 31062fddbfbbf25f206060b38773a7e2c008723c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 03:32:43 2025 +0200\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex f76d6dd..7a725b4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -18,7 +18,7 @@ run:\n \t.venv/usr/bin/snek\n \t\n-install:\n+install: ubuntu\n \tpython3.12 -m venv .venv \n \t$(PIP) install -e .\n \ndiff --git a/terminal/.bashrc b/terminal/.bashrc\nindex 533c911..eadad98 100644\n--- a/terminal/.bashrc\n+++ b/terminal/.bashrc\n@@ -119,9 +119,10 @@ function r_update(){\n     mv r /usr/local/bin/r\n }\n \n+. \"$HOME/.cargo/env\"\n \n resize > /dev/null \n \n r_update \n \n-r\n+"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Install tmux", "commit": "3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9", "diff": "commit 3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 8 02:05:15 2025 +0000\n\n    ADded tmux.\n\ndiff --git a/DockerfileUbuntu b/DockerfileUbuntu\nindex 8f6ddd5..161cdf0 100644\n--- a/DockerfileUbuntu\n+++ b/DockerfileUbuntu\n@@ -1,6 +1,6 @@\n FROM ubuntu:latest\n \n-RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx -y\n+RUN apt update && apt install libreadline-dev libcurl4-openssl-dev libssl-dev libncurses5-dev libncursesw5-dev libsqlite3-dev libreadline6-dev zlib1g-dev libbz2-dev libffi-dev liblzma-dev python3 python3-pip python3-venv libjson-c-dev vim htop git curl wget xterm valgrind ack irssi lynx tmux -y\n "}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement user template loading based on admin status", "commit": "02a0253c1d9c73d2918fecb8f52c4c7739c867f5", "diff": "commit 02a0253c1d9c73d2918fecb8f52c4c7739c867f5\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 01:33:41 2025 +0200\n\n    YEah..\n\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 1f3e1e9..c4996aa 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,32 +1,37 @@\n import argparse\n+\n from aiohttp import web\n \n from snek.app import Application\n \n+\n def main():\n     parser = argparse.ArgumentParser(description=\"Run the web application.\")\n     parser.add_argument(\n         \"--port\",\n         type=int,\n         default=8081,\n-        help=\"Port to run the application on (default: 8081)\"\n+        help=\"Port to run the application on (default: 8081)\",\n     )\n     parser.add_argument(\n         \"--host\",\n         type=str,\n         default=\"0.0.0.0\",\n-        help=\"Host to run the application on (default: 0.0.0.0)\"\n+        help=\"Host to run the application on (default: 0.0.0.0)\",\n     )\n     parser.add_argument(\n         \"--db_path\",\n         type=str,\n         default=\"snek.db\",\n     )\n-    \n+\n     args = parser.parse_args()\n-    \n+\n+    web.run_app(\n+    )\n+\n \n if __name__ == \"__main__\":\n     main()\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a1c8938..e65264e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -17,8 +17,9 @@ from aiohttp_session import (\n     setup as session_setup,\n )\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n-\n from app.app import Application as BaseApplication\n+from jinja2 import FileSystemLoader\n+\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n@@ -40,12 +41,12 @@ from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n+from snek.view.stats import StatsView\n from snek.view.status import StatusView\n from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n-from snek.view.web import WebView\n-from snek.view.stats import StatsView\n from snek.view.user import UserView\n+from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n@@ -204,7 +205,6 @@ class Application(BaseApplication):\n         channels = []\n         if not context:\n             context = {}\n-\n         context[\"rid\"] = str(uuid.uuid4())\n         if request.session.get(\"uid\"):\n             async for subscribed_channel in self.services.channel_member.find(\n@@ -231,7 +231,6 @@ class Application(BaseApplication):\n                     item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n                 item[\"new_count\"] = subscribed_channel[\"new_count\"]\n \n-                print(item)\n                 channels.append(item)\n \n             channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n@@ -239,10 +238,37 @@ class Application(BaseApplication):\n                 context[\"channels\"] = channels\n             if \"user\" not in context:\n                 context[\"user\"] = await self.services.user.get(\n-                    uid=request.session.get(\"uid\")\n+                    request.session.get(\"uid\")\n                 )\n \n-        return await super().render_template(template, request, context)\n+        self.template_path.joinpath(template)\n+\n+        await self.services.user.get_template_path(request.session.get(\"uid\"))\n+\n+        self.original_loader = self.jinja2_env.loader\n+\n+        self.jinja2_env.loader = await self.get_user_template_loader(\n+            request.session.get(\"uid\")\n+        )\n+\n+        rendered = await super().render_template(template, request, context)\n+\n+        self.jinja2_env.loader = self.original_loader\n+\n+        return rendered\n+\n+    async def get_user_template_loader(self, uid=None):\n+        template_paths = []\n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_template_path = await self.services.user.get_template_path(admin_uid)\n+            template_paths.append(user_template_path)\n+\n+        if uid:\n+            user_template_path = await self.services.user.get_template_path(uid)\n+            template_paths.append(user_template_path)\n+\n+        template_paths.append(self.template_path)\n+        return FileSystemLoader(template_paths)\n \n \ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex dcbd6f8..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,8 +1,8 @@\n import pathlib\n \n from aiohttp import web\n-\n from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n \n \ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex c388abc..e0df494 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -5,3 +5,16 @@ from snek.system.mapper import BaseMapper\n class UserMapper(BaseMapper):\n     table_name = \"user\"\n     model_class = UserModel\n+\n+    def get_admin_uids(self):\n+        try:\n+            return [\n+                user[\"uid\"]\n+                for user in self.db.query(\n+                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n+                    {\"is_admin\": True},\n+                )\n+            ]\n+        except Exception as ex:\n+            print(ex)\n+            return []\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 89d46ba..9869456 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -29,6 +29,8 @@ class UserModel(BaseModel):\n \n     last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n \n+    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n+\n     async def get_property(self, name):\n         prop = await self.app.services.user_property.find_one(\n             user_uid=self[\"uid\"], name=name\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex a2300b6..df96786 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -11,9 +11,12 @@ class ChannelMemberService(BaseService):\n         return await self.save(channel_member)\n \n     async def get_user_uids(self, channel_uid):\n-        async for model in self.mapper.query(\"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\", {\"channel_uid\": channel_uid}):\n+        async for model in self.mapper.query(\n+            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n+            {\"channel_uid\": channel_uid},\n+        ):\n             yield model[\"user_uid\"]\n-    \n+\n     async def create(\n         self,\n         channel_uid,\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex c084eb9..a3654d2 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -15,7 +15,7 @@ class SocketService(BaseService):\n                 return False\n             try:\n                 await self.ws.send_json(data)\n-            except Exception as ex:\n+            except Exception:\n                 self.is_connected = False\n             return self.is_connected\n \n@@ -56,7 +56,9 @@ class SocketService(BaseService):\n \n     async def broadcast(self, channel_uid, message):\n         try:\n-            async for user_uid in self.services.channel_member.get_user_uids(channel_uid):\n+            async for user_uid in self.services.channel_member.get_user_uids(\n+                channel_uid\n+            ):\n                 print(user_uid, flush=True)\n                 await self.send_to_user(user_uid, message)\n         except Exception as ex:\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex c527361..c0734dc 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -39,6 +39,15 @@ class UserService(BaseService):\n         model = await self.get(username=username, deleted_at=None)\n         return model\n \n+    def get_admin_uids(self):\n+        return self.mapper.get_admin_uids()\n+\n+    async def get_template_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\n         if not folder.exists():\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 49a120d..4d11fa8 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -9,16 +9,18 @@ class UserPropertyService(BaseService):\n     async def set(self, user_uid, name, value):\n         self.mapper.db[\"user_property\"].upsert(\n             {\n-                \"user_uid\": user_uid, \n-                \"name\": name, \n-                \"value\": json.dumps(value, default=str)\n+                \"user_uid\": user_uid,\n+                \"name\": name,\n+                \"value\": json.dumps(value, default=str),\n             },\n-            [\"user_uid\", \"name\"]\n+            [\"user_uid\", \"name\"],\n         )\n-        \n+\n     async def get(self, user_uid, name):\n         try:\n-            return json.loads((await super().get(user_uid=user_uid, name=name))[\"value\"])\n+            return json.loads(\n+                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n+            )\n         except Exception as ex:\n             print(ex)\n             return None\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 0ecfd47..eed888a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -18,7 +18,7 @@ class Cache:\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n-        await self.update_stat(args, 'get')\n+        await self.update_stat(args, \"get\")\n         try:\n             self.lru.pop(self.lru.index(args))\n         except:\n@@ -34,20 +34,28 @@ class Cache:\n     async def get_stats(self):\n         all_ = []\n         for key in self.lru:\n-            all_.append({'key': key, 'set': self.stats[key]['set'], 'get': self.stats[key]['get'], 'delete': self.stats[key]['delete'],'value': str(self.serialize(self.cache[key].record))})\n+            all_.append(\n+                {\n+                    \"key\": key,\n+                    \"set\": self.stats[key][\"set\"],\n+                    \"get\": self.stats[key][\"get\"],\n+                    \"delete\": self.stats[key][\"delete\"],\n+                    \"value\": str(self.serialize(self.cache[key].record)),\n+                }\n+            )\n         return all_\n \n     def serialize(self, obj):\n         cpy = obj.copy()\n-        cpy.pop('created_at', None)\n-        cpy.pop('deleted_at', None)\n-        cpy.pop('email', None)\n-        cpy.pop('password', None)\n+        cpy.pop(\"created_at\", None)\n+        cpy.pop(\"deleted_at\", None)\n+        cpy.pop(\"email\", None)\n+        cpy.pop(\"password\", None)\n         return cpy\n \n     async def update_stat(self, key, action):\n-        if not key in self.stats:\n-            self.stats[key] = {'set':0, 'get':0, 'delete':0}\n+        if key not in self.stats:\n+            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n         self.stats[key][action] = self.stats[key][action] + 1\n \n     def json_default(self, value):\n@@ -70,7 +78,7 @@ class Cache:\n     async def set(self, args, result):\n         is_new = args not in self.cache\n         self.cache[args] = result\n-        await self.update_stat(args, 'set')\n+        await self.update_stat(args, \"set\")\n         try:\n             self.lru.pop(self.lru.index(args))\n         except (ValueError, IndexError):\n@@ -86,7 +94,7 @@ class Cache:\n \n     async def delete(self, args):\n-        await self.update_stat(args, 'delete')\n+        await self.update_stat(args, \"delete\")\n         if args in self.cache:\n             try:\n                 self.lru.pop(self.lru.index(args))\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex 2b59636..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -32,9 +32,8 @@ from urllib.parse import urljoin\n \n import aiohttp\n import imgkit\n-from bs4 import BeautifulSoup\n-\n from app.cache import time_cache_async\n+from bs4 import BeautifulSoup\n \n \n async def crc32(data):\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 53b5db8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -2,13 +2,12 @@\n \n from types import SimpleNamespace\n \n+from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n \n-from app.cache import time_cache_async\n-\n \n class MarkdownRenderer(HTMLRenderer):\n \ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 2120bf4..c5410b6 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -16,12 +16,12 @@ commands = {\n \n class TerminalSession:\n     def __init__(self, command):\n-        self.master, self.slave = None,None\n+        self.master, self.slave = None, None\n         self.process = None\n         self.sockets = []\n         self.history = b\"\"\n         self.history_size = 1024 * 20\n-        self.command = command \n+        self.command = command\n         self.start_process(self.command)\n \n     def start_process(self, command):\n@@ -29,7 +29,7 @@ class TerminalSession:\n             if self.master:\n                 os.close(self.master)\n                 os.close(self.slave)\n-                self.master = None \n+                self.master = None\n                 self.slave = None\n \n             self.master, self.slave = pty.openpty()\n@@ -45,7 +45,7 @@ class TerminalSession:\n     def is_running(self):\n         if not self.process:\n             return False\n-        loop = asyncio.get_event_loop()\n+        asyncio.get_event_loop()\n         return self.process.poll() is None\n \n     async def add_websocket(self, ws):\n@@ -78,7 +78,7 @@ class TerminalSession:\n                     self.sockets.remove(ws)\n             except Exception:\n                 await self.close()\n-                break \n+                break\n \n     async def close(self):\n         print(\"Terminating process\")\n@@ -88,8 +88,8 @@ class TerminalSession:\n         if self.master:\n             os.close(self.master)\n             os.close(self.slave)\n-            self.master = None \n-            self.slave = None \n+            self.master = None\n+            self.slave = None\n \n         print(\"Terminated process\")\n         for ws in self.sockets:\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 981a2e5..70379ef 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -8,7 +8,9 @@ class BaseView(web.View):\n     login_required = False\n \n     async def _iter(self):\n-        if self.login_required and (not self.session.get(\"logged_in\") or not self.session.get(\"uid\")):\n+        if self.login_required and (\n+            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n+        ):\n             return web.HTTPFound(\"/\")\n         return await super()._iter()\n \ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex c8b1409..2f44443 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -10,9 +10,11 @@\n \n \n-from snek.system.view import BaseView\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class IndexView(BaseView):\n     async def get(self):\n         if self.session.get(\"uid\"):\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex d6d93c8..1f09a26 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -35,6 +35,7 @@ from snek.system.view import BaseFormView\n class SearchUserView(BaseFormView):\n     form = SearchUserForm\n     login_required = True\n+\n     async def get(self):\n         users = []\n         query = self.request.query.get(\"query\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 52f46df..164c526 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -17,24 +17,22 @@ class SettingsProfileView(BaseFormView):\n \n             return web.json_response(await form.to_json())\n \n+        profile = await self.services.user_property.get(\n+            self.session.get(\"uid\"), \"profile\"\n+        )\n \n-\n-        profile = await self.services.user_property.get(self.session.get(\"uid\"), \"profile\")\n-        \n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n         return await self.render_template(\n-            \"settings/profile.html\", {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or ''}\n+            \"settings/profile.html\",\n+            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n         )\n \n     async def post(self):\n         data = await self.request.post()\n         user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-         \n-        user['nick'] = data['nick']\n-        await self.services.user.save(user) \n-        await self.services.user_property.set(user[\"uid\"],\"profile\", data['profile'])\n-        return web.HTTPFound(\"/settings/profile.html\")\n-        \n-\n \n+        user[\"nick\"] = data[\"nick\"]\n+        await self.services.user.save(user)\n+        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n+        return web.HTTPFound(\"/settings/profile.html\")\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex 73714ce..1680c5c 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,10 +1,13 @@\n-from snek.system.view import BaseView \n-import json \n+import json\n+\n from aiohttp import web\n \n+from snek.system.view import BaseView\n+\n+\n class StatsView(BaseView):\n-    \n+\n     async def get(self):\n         data = await self.app.cache.get_stats()\n         data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n-        return web.Response(text=data, content_type='application/json')\n+        return web.Response(text=data, content_type=\"application/json\")\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex 01c0ee7..cf01948 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -38,9 +38,9 @@ class UploadView(BaseView):\n         user_uid = self.request.session.get(\"uid\")\n \n         upload_dir = await self.services.user.get_home_folder(user_uid)\n-        upload_dir = upload_dir.joinpath(\"upload\") \n+        upload_dir = upload_dir.joinpath(\"upload\")\n         upload_dir.mkdir(parents=True, exist_ok=True)\n-        \n+\n         channel_uid = None\n \n         drive = await self.services.drive.get_or_create(\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex d29b8f3..312f7bf 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -2,13 +2,14 @@ from snek.system.view import BaseView\n \n \n class UserView(BaseView):\n-    \n+\n     async def get(self):\n-        user_uid = self.request.match_info.get('user')\n+        user_uid = self.request.match_info.get(\"user\")\n         user = await self.services.user.get(uid=user_uid)\n-        profile_content = await self.services.user_property.get(user['uid'],'profile') or ''\n-        return await self.render_template('user.html', {\n-            'user_uid': user_uid,\n-            'user': user.record,\n-            'profile': profile_content  \n-        })\n+        profile_content = (\n+            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n+        )\n+        return await self.render_template(\n+            \"user.html\",\n+            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n+        )\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 6025038..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -12,9 +12,8 @@ import uuid\n import aiofiles\n import aiohttp\n import aiohttp.web\n-from lxml import etree\n-\n from app.cache import time_cache_async\n+from lxml import etree\n \n \n @aiohttp.web.middleware"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Prevent appending None to template paths", "commit": "165dda32100e52c347c7f6bb71062244b2a50ba1", "diff": "commit 165dda32100e52c347c7f6bb71062244b2a50ba1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 01:36:48 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e65264e..ece5b7b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -265,7 +265,9 @@ class Application(BaseApplication):\n \n         if uid:\n             user_template_path = await self.services.user.get_template_path(uid)\n-            template_paths.append(user_template_path)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n \n         template_paths.append(self.template_path)\n         return FileSystemLoader(template_paths)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving and add user-specific static paths", "commit": "ac570d036c26b6c7ff5abac169fdf622c10827ad", "diff": "commit ac570d036c26b6c7ff5abac169fdf622c10827ad\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:08:43 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ece5b7b..32d83e9 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -75,6 +75,7 @@ class Application(BaseApplication):\n             web.normalize_path_middleware(merge_slashes=True),\n         ]\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n         super().__init__(\n             middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n         )\n@@ -136,12 +137,7 @@ class Application(BaseApplication):\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n-        self.router.add_static(\n-            \"/\",\n-            pathlib.Path(__file__).parent.joinpath(\"static\"),\n-            name=\"static\",\n-            show_index=True,\n-        )\n+        self.router.add_get(\"/static/{file_path:.*}\", self.static_handler)\n         self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n@@ -257,11 +253,37 @@ class Application(BaseApplication):\n \n         return rendered\n \n+\n+    async def static_handler(request):\n+        file_name = request.match_info.get('filename', '')\n+\n+        paths = []\n+\n+\n+        uid = self.request.session.get(\"uid\")\n+        if uid:\n+            user_static_path = await self.services.user.get_static_path(uid)\n+            if user_static_path:\n+                paths.append(user_template_path)\n+      \n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_static_path = await self.services.user.get_static_path(admin_uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+        \n+        paths.append(self.static_path)\n+\n+        for path in paths:\n+            if pathlib.Path(path).joinpath(file_name).exists():\n+                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n+        return web.HTTPNotFound()\n+\n     async def get_user_template_loader(self, uid=None):\n         template_paths = []\n         for admin_uid in self.services.user.get_admin_uids():\n             user_template_path = await self.services.user.get_template_path(admin_uid)\n-            template_paths.append(user_template_path)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n \n         if uid:\n             user_template_path = await self.services.user.get_template_path(uid)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex c0734dc..fb66ddb 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,6 +42,14 @@ class UserService(BaseService):\n     def get_admin_uids(self):\n         return self.mapper.get_admin_uids()\n \n+    async def get_static_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+\n+\n     async def get_template_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n         if not path.exists():"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Pass self to static_handler", "commit": "b867b6ba78574a332bf951eb6c00a6a88ded325d", "diff": "commit b867b6ba78574a332bf951eb6c00a6a88ded325d\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:15:57 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 32d83e9..7a95d01 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -254,7 +254,7 @@ class Application(BaseApplication):\n         return rendered\n \n \n-    async def static_handler(request):\n+    async def static_handler(self, request):\n         file_name = request.match_info.get('filename', '')\n \n         paths = []"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use request.session instead of self.request.session", "commit": "e359a8ebe294e0f55cf4164926011e893468e4bc", "diff": "commit e359a8ebe294e0f55cf4164926011e893468e4bc\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:16:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7a95d01..1c974a5 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -260,7 +260,7 @@ class Application(BaseApplication):\n         paths = []\n \n \n-        uid = self.request.session.get(\"uid\")\n+        uid = request.session.get(\"uid\")\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)\n             if user_static_path:"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use user static path for templates", "commit": "5b28044d9e604b2404bf4b7277a240f7fc56032c", "diff": "commit 5b28044d9e604b2404bf4b7277a240f7fc56032c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:17:12 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 1c974a5..c340eef 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -264,7 +264,7 @@ class Application(BaseApplication):\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)\n             if user_static_path:\n-                paths.append(user_template_path)\n+                paths.append(user_static_path)\n       \n         for admin_uid in self.services.user.get_admin_uids():\n             user_static_path = await self.services.user.get_static_path(admin_uid)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with `add_static`", "commit": "c56bf4fb49c986e9b653f635a81937a3ef433a5e", "diff": "commit c56bf4fb49c986e9b653f635a81937a3ef433a5e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 02:30:43 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex c340eef..605934a 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -137,7 +137,12 @@ class Application(BaseApplication):\n \n     def setup_router(self):\n         self.router.add_get(\"/\", IndexView)\n-        self.router.add_get(\"/static/{file_path:.*}\", self.static_handler)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n         self.router.add_view(\"/profiler.html\", profiler_handler)\n         self.router.add_view(\"/about.html\", AboutHTMLView)\n         self.router.add_view(\"/about.md\", AboutMDView)\n@@ -177,7 +182,9 @@ class Application(BaseApplication):\n             \"/docs\",\n             DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n         )\n-\n+        \n+    \n     async def handle_test(self, request):\n \n         return await self.render_template(\n@@ -259,7 +266,6 @@ class Application(BaseApplication):\n \n         paths = []\n \n-\n         uid = request.session.get(\"uid\")\n         if uid:\n             user_static_path = await self.services.user.get_static_path(uid)"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository management functionality\n\nThis commit introduces repository management features, including creation, updating, and deletion. It also includes UI updates to the settings sidebar and templates.\n", "commit": "ee40c905d4448f0b39d28c4d51343b5fe111d038", "diff": "commit ee40c905d4448f0b39d28c4d51343b5fe111d038\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 05:38:29 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex c4996aa..198ea1e 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,11 +1,14 @@\n import argparse\n-\n+import uvloop\n from aiohttp import web\n-\n+import asyncio\n from snek.app import Application\n \n \n def main():\n+    \n+    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    \n     parser = argparse.ArgumentParser(description=\"Run the web application.\")\n     parser.add_argument(\n         \"--port\",\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 605934a..0b5e14b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -39,6 +39,10 @@ from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n from snek.view.search_user import SearchUserView\n+from snek.view.settings.repositories import RepositoriesIndexView\n+from snek.view.settings.repositories import RepositoriesCreateView\n+from snek.view.settings.repositories import RepositoriesUpdateView\n+from snek.view.settings.repositories import RepositoriesDeleteView\n from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n@@ -175,6 +179,10 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n+        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n+        self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n \ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 2d4b12c..ab7904f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -8,6 +8,7 @@ from snek.mapper.drive_item import DriveItemMapper\n from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n+from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n \n \n@@ -23,6 +24,7 @@ def get_mappers(app=None):\n             \"drive_item\": DriveItemMapper(app=app),\n             \"drive\": DriveMapper(app=app),\n             \"user_property\": UserPropertyMapper(app=app),\n+            \"repository\": RepositoryMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nnew file mode 100644\nindex 0000000..1ac10d4\n--- /dev/null\n+++ b/src/snek/mapper/repository.py\n@@ -0,0 +1,7 @@\n+from snek.model.repository import RepositoryModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class RepositoryMapper(BaseMapper):\n+    model_class = RepositoryModel\n+    table_name = \"repository\"\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex a1009a5..6399c89 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -10,6 +10,7 @@ from snek.model.drive_item import DriveItemModel\n from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n+from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n \n \n@@ -25,6 +26,7 @@ def get_models():\n             \"drive\": DriveModel,\n             \"notification\": NotificationModel,\n             \"user_property\": UserPropertyModel,\n+            \"repository\": RepositoryModel,\n         }\n     )\n \ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nnew file mode 100644\nindex 0000000..598cbb2\n--- /dev/null\n+++ b/src/snek/model/repository.py\n@@ -0,0 +1,14 @@\n+from snek.model.user import UserModel\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class RepositoryModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    \n+    name = ModelField(name=\"name\", required=True, kind=str)\n+\n+    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n+\n+\n+    \ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 3ec4592..be356dc 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -11,6 +11,7 @@ from snek.service.socket import SocketService\n from snek.service.user import UserService\n from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n+from snek.service.repository import RepositoryService\n from snek.system.object import Object\n \n \n@@ -29,6 +30,7 @@ def get_services(app):\n             \"drive\": DriveService(app=app),\n             \"drive_item\": DriveItemService(app=app),\n             \"user_property\": UserPropertyService(app=app),\n+            \"repository\": RepositoryService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nnew file mode 100644\nindex 0000000..f7694d7\n--- /dev/null\n+++ b/src/snek/service/repository.py\n@@ -0,0 +1,39 @@\n+from snek.system.service import BaseService\n+import asyncio \n+\n+class RepositoryService(BaseService):\n+    mapper_name = \"repository\"\n+\n+    async def exists(self, user_uid, name, **kwargs):\n+        kwargs[\"user_uid\"] = user_uid\n+        kwargs[\"name\"] = name\n+        return await self.exists(**kwargs)\n+\n+    async def init(self, user_uid, name):\n+        repository_path = await self.services.user.get_repository_path(user_uid)\n+        if not repository_path.exists():\n+            repository_path.mkdir(parents=True)\n+        repository_path = repository_path.joinpath(name)\n+        command = ['git', 'init', '--bare', repository_path]\n+        process = await asyncio.subprocess.create_subprocess_exec(\n+            *command,\n+            stdout=asyncio.subprocess.PIPE,\n+            stderr=asyncio.subprocess.PIPE\n+        )\n+        stdout, stderr = await process.communicate()\n+        if process.returncode == 0:\n+            print(f\"Bare Git repository created at: {repo_path}\")\n+        else:\n+            print(f\"Error creating repository: {stderr.decode().strip()}\")\n+\n+    async def create(self, user_uid, name,is_private=False):\n+        if await self.exists(user_uid=user_uid, name=name):\n+            return False \n+\n+\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        model[\"is_private\"] = is_private\n+        return await self.save(model)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex fb66ddb..7ca0711 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -42,6 +42,12 @@ class UserService(BaseService):\n     def get_admin_uids(self):\n         return self.mapper.get_admin_uids()\n \n+    async def get_repository_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n     async def get_static_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n         if not path.exists():\ndiff --git a/src/snek/templates/settings/repositories/create.html b/src/snek/templates/settings/repositories/create.html\nnew file mode 100644\nindex 0000000..cfef080\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/create.html\n@@ -0,0 +1,59 @@\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-plus\"></i> Create Repository</h1>{% endblock %}\n+\n+{% block main %}\n+\n+<style>\n+.container {\n+    div,input,label,button{\n+        padding-bottom: 15px;\n+    }\n+}\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    input[type=\"text\"] {\n+      padding: 0.5rem;\n+      font-size: 1rem;\n+    }\n+\n+\n+button, a.button {\n+      padding: 0.1rem 0.8rem; text-decoration: none; cursor: pointer;\n+      transition: background 0.2s;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .\n+    .cancel {\n+    }\n+    @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+    <form action=\"/settings/repositories/create.html\" method=\"post\">\n+      <div>\n+        <label for=\"name\"><i class=\"fa-solid fa-book\"></i> Name</label>\n+        <input type=\"text\" id=\"name\" name=\"name\" required placeholder=\"Repository name\">\n+      </div>\n+      <div>\n+        <label>\n+          <input type=\"checkbox\" name=\"is_private\" value=\"1\">\n+          <i class=\"fa-solid fa-lock\"></i> Private\n+        </label>\n+      </div>\n+      <button type=\"submit\"><i class=\"fa-solid fa-plus\"></i> Create</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Back</button> \n+    </form>\n+  </div>\n+  {% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nnew file mode 100644\nindex 0000000..af2a906\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -0,0 +1,63 @@\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <title>Delete Repository</title>\n+  <style>\n+    body { font-family: sans-serif; margin: 2rem; }\n+    .container { max-width: 400px; margin: 0 auto; }\n+    .confirm-box {\n+      padding: 2rem;\n+      border-radius: 10px;\n+      text-align: center;\n+      margin-top: 2rem;\n+    }\n+    .repo-name {\n+      font-weight: bold;\n+      font-size: 1.2rem;\n+      margin: 1rem 0;\n+    }\n+    .actions {\n+      display: flex; gap: 1rem; justify-content: center; margin-top: 1.5rem;\n+    }\n+    button, a {\n+      border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n+      font-size: 1rem; cursor: pointer;\n+      display: flex; align-items: center; gap: 0.5rem; text-decoration: none; justify-content: center;\n+      transition: background 0.2s;\n+    }\n+    .cancel {\n+    }\n+    @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      .confirm-box { padding: 1rem; }\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+    <h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>\n+    <div class=\"confirm-box\">\n+      <div>\n+      </div>\n+      <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n+      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> my-first-repo</div>\n+      <form action=\"/repositories/delete\" method=\"post\" style=\"margin-top:1.5rem;\">\n+        <input type=\"hidden\" name=\"id\" value=\"1\">\n+        <div class=\"actions\">\n+          <button type=\"submit\"><i class=\"fa-solid fa-trash\"></i> Yes, delete</button>\n+          <a href=\"repositories.html\" class=\"cancel\"><i class=\"fa-solid fa-ban\"></i> Cancel</a>\n+        </div>\n+      </form>\n+    </div>\n+  </div>\n+</body>\n+</html>\n+\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nnew file mode 100644\nindex 0000000..a160736\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -0,0 +1,106 @@\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-database\"></i> Repositories</h1>{% endblock %}\n+\n+{% block main %}\n+<!DOCTYPE html>\n+<html lang=\"en\">\n+<head>\n+  <meta charset=\"UTF-8\">\n+  <title>Repositories - List</title>\n+  <style>\n+    .actions {\n+      display: flex;\n+      gap: 0.5rem;\n+      justify-content: center;\n+      flex-wrap: wrap;\n+    }\n+    .repo-list {\n+      display: flex;\n+      flex-direction: column;\n+      gap: 1rem;\n+    }\n+    .repo-row {\n+      display: flex;\n+      align-items: center;\n+      justify-content: space-between;\n+      padding: 1rem;\n+      border-radius: 8px;\n+      flex-wrap: wrap;\n+    }\n+    .repo-info {\n+      display: flex;\n+      align-items: center;\n+      gap: 1rem;\n+      flex: 1;\n+      min-width: 220px;\n+    }\n+    .repo-name {\n+      font-size: 1.1rem;\n+      font-weight: 600;\n+    }\n+    @media (max-width: 600px) {\n+      .repo-row { flex-direction: column; align-items: stretch; }\n+      .actions { justify-content: flex-start; }\n+    }\n+    .topbar {\n+      display: flex;\n+      margin-bottom: 1rem;\n+    }\n+    button, a.button {\n+      padding: 0.4rem 0.8rem; text-decoration: none; cursor: pointer;\n+      transition: background 0.2s;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+  </style>\n+</head>\n+<body>\n+  <div class=\"container\">\n+      \n+    <div class=\"topbar\">\n+      <a class=\"button create\" href=\"/settings/repositories/create.html\">\n+        <i class=\"fa-solid fa-plus\"></i> New Repository\n+      </a>\n+    </div>\n+    <section class=\"repo-list\">\n+      <!-- Example repository entries; replace with your templating/iteration -->\n+      {% for repo in repositories %}\n+      <div class=\"repo-row\">\n+        <div class=\"repo-info\">\n+            <span class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repo.name }}</span>\n+        \n+<span title=\"Public\">\n+    <i class=\"fa-solid {% if repo.is_private %}fa-lock{% else %}fa-lock-open{% endif %}\"></i>\n+    {% if repo.is_private %}Private{% else %}Public{% endif %}\n+</span>\n+        </div>\n+        <div class=\"actions\">\n+            <a class=\"button browse\" href=\"/repositories/{{ user.username }}/{{ repo.name }}\" target=\"_blank\">\n+            <i class=\"fa-solid fa-folder-open\"></i> Browse\n+          </a>\n+          <a class=\"button clone\" href=\"/repositories/{{ user.username }}/{{ repo.name }}/clone\">\n+            <i class=\"fa-solid fa-code-branch\"></i> Clone\n+          </a>\n+          <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\n+            <i class=\"fa-solid fa-pen\"></i> Edit\n+          </a>\n+          <a class=\"button delete\" href=\"/settings/repositories/{{ repo.name }}/delete.html\">\n+            <i class=\"fa-solid fa-trash\"></i> Delete\n+          </a>\n+        </div>\n+      </div>\n+      {% endfor %}\n+      <!-- ... -->\n+    </section>\n+  </div>\n+</body>\n+</html>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/update.html b/src/snek/templates/settings/repositories/update.html\nnew file mode 100644\nindex 0000000..5168c92\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/update.html\n@@ -0,0 +1,45 @@\n+{% extends \"settings/index.html\" %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-pen\"></i> Update Repository</h1>{% endblock %}\n+\n+{% block main %}\n+  <style>\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    button {\n+      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n+      cursor: pointer;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .cancel {\n+    }\n+      @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+    }\n+  </style>\n+  <div class=\"container\">\n+    <form method=\"post\">\n+      <!-- Assume hidden id for backend use -->\n+      <input type=\"hidden\" name=\"id\" value=\"{{ repository.id }}\">\n+      <div>\n+        <label for=\"name\"><i class=\"fa-solid fa-book\"></i> Name</label>\n+        <input type=\"text\" id=\"name\" name=\"name\" value=\"{{ repository.name }}\" readonly>\n+      </div>\n+      <div>\n+        <label>\n+            <input type=\"checkbox\" name=\"is_private\" value=\"1\" {% if repository.is_private %}checked{% endif %}>\n+            <i class=\"fa-solid fa-lock\"></i> Private\n+        </label>\n+      </div>\n+      <button type=\"submit\"><i class=\"fa-solid fa-pen\"></i> Update</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i>Cancel</button>\n+    </form>\n+  </div>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/sidebar.html b/src/snek/templates/settings/sidebar.html\nindex f9533be..9673b6e 100644\n--- a/src/snek/templates/settings/sidebar.html\n+++ b/src/snek/templates/settings/sidebar.html\n@@ -3,7 +3,7 @@\n     <h2>You</h2>\n     <ul>\n         <li><a class=\"no-select\" href=\"/settings/profile.html\">Profile</a></li>\n-        <li><a class=\"no-select\" href=\"/settings/gists.html\">Gists</a></li>\n+        <li><a class=\"no-select\" href=\"/settings/repositories/index.html\">Repositories</a></li>\n     </ul>\n \n   </aside>\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nnew file mode 100644\nindex 0000000..1b652e6\n--- /dev/null\n+++ b/src/snek/view/settings/repositories.py\n@@ -0,0 +1,68 @@\n+import asyncio\n+from aiohttp import web\n+\n+from snek.system.view import BaseFormView\n+import pathlib\n+\n+class RepositoriesIndexView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        user_uid = self.session.get(\"uid\")\n+        \n+        repositories = []\n+        async for repository in self.services.repository.find(user_uid=user_uid):\n+            repositories.append(repository.record)\n+\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories})\n+\n+\n+\n+\n+class RepositoriesCreateView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/create.html\")\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+class RepositoriesUpdateView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        repository['is_private'] = int(data.get('is_private',0))\n+        await self.services.repository.save(repository)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+class RepositoriesDeleteView(BaseFormView):\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/delete.html\")\n+\n+\n+"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added basic Git repository management functionality", "commit": "a5aac9a33701e3d4852fba13520771e6de82aac0", "diff": "commit a5aac9a33701e3d4852fba13520771e6de82aac0\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 07:55:08 2025 +0200\n\n    Patch\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cce4bd7..1a5ac0c 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -31,7 +31,8 @@ dependencies = [\n     \"emoji\",\n     \"aiofiles\",\n     \"PyJWT\",\n-    \"multiavatar\"\n+    \"multiavatar\",\n+    \"gitpython\",\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0b5e14b..7e5e2c4 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -52,6 +52,7 @@ from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n+from snek.sgit import GitApplication\n \n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n@@ -184,12 +185,9 @@ class Application(BaseApplication):\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n         self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n+        self.git = GitApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\n-\n-        self.add_subapp(\n-            \"/docs\",\n-            DocsApplication(path=pathlib.Path(__file__).parent.joinpath(\"docs\")),\n-        )\n+        self.add_subapp(\"/git\",self.git)\n         \n     \ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex f7694d7..93bba77 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -7,7 +7,7 @@ class RepositoryService(BaseService):\n     async def exists(self, user_uid, name, **kwargs):\n         kwargs[\"user_uid\"] = user_uid\n         kwargs[\"name\"] = name\n-        return await self.exists(**kwargs)\n+        return await super().exists(**kwargs)\n \n     async def init(self, user_uid, name):\n         repository_path = await self.services.user.get_repository_path(user_uid)\n@@ -21,16 +21,14 @@ class RepositoryService(BaseService):\n             stderr=asyncio.subprocess.PIPE\n         )\n         stdout, stderr = await process.communicate()\n-        if process.returncode == 0:\n-            print(f\"Bare Git repository created at: {repo_path}\")\n-        else:\n-            print(f\"Error creating repository: {stderr.decode().strip()}\")\n+        return process.returncode == 0\n \n     async def create(self, user_uid, name,is_private=False):\n         if await self.exists(user_uid=user_uid, name=name):\n             return False \n \n-\n+        if not await self.init(user_uid=user_uid, name=name):\n+            return False\n \n         model = await self.new()\n         model[\"user_uid\"] = user_uid\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 7ca0711..7b727f7 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -43,10 +43,7 @@ class UserService(BaseService):\n         return self.mapper.get_admin_uids()\n \n     async def get_repository_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-        if not path.exists():\n-            return None\n-        return path\n+        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n \n     async def get_static_path(self, user_uid):\n         path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nnew file mode 100644\nindex 0000000..6955288\n--- /dev/null\n+++ b/src/snek/sgit.py\n@@ -0,0 +1,472 @@\n+import os\n+import aiohttp\n+from aiohttp import web\n+import git\n+import shutil\n+import json\n+import tempfile\n+import asyncio\n+import logging\n+import base64\n+import pathlib\n+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger = logging.getLogger('git_server')\n+\n+class GitApplication(web.Application):\n+    def __init__(self, parent=None):\n+        self.parent = parent\n+        super().__init__(client_max_size=100*1024*1024)\n+        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n+        self.USERS = {\n+            'x': 'x',\n+            'bob': 'bobpass',\n+        }\n+        self.add_routes([\n+            web.post('/create/{repo_name}', self.create_repository),\n+            web.delete('/delete/{repo_name}', self.delete_repository),\n+            web.get('/clone/{repo_name}', self.clone_repository),\n+            web.post('/push/{repo_name}', self.push_repository),\n+            web.post('/pull/{repo_name}', self.pull_repository),\n+            web.get('/status/{repo_name}', self.status_repository),\n+            web.get('/list', self.list_repositories),\n+            web.get('/branches/{repo_name}', self.list_branches),\n+            web.post('/branches/{repo_name}', self.create_branch),\n+            web.get('/log/{repo_name}', self.commit_log),\n+            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n+            web.get('/{path:.+}/info/refs', self.git_smart_http),\n+            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n+            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n+            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n+        ])\n+\n+    async def check_basic_auth(self, request):\n+        return \"retoor\", pathlib.Path(self.REPO_DIR)\n+\n+    @staticmethod\n+    def require_auth(handler):\n+        async def wrapped(self, request, *args, **kwargs):\n+            username, repository_path = await self.check_basic_auth(request)\n+            if not username or not repository_path:\n+                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n+            request['username'] = username\n+            request['repository_path'] = repository_path\n+            return await handler(self, request, *args, **kwargs)\n+        return wrapped\n+\n+    def repo_path(self, repository_path, repo_name):\n+        return repository_path.joinpath(repo_name + '.git')\n+\n+    def check_repo_exists(self, repository_path, repo_name):\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if not os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository not found\", status=404)\n+        return None\n+\n+    @require_auth\n+    async def create_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        if not repo_name or '/' in repo_name or '..' in repo_name:\n+            return web.Response(text=\"Invalid repository name\", status=400)\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository already exists\", status=400)\n+        try:\n+            git.Repo.init(repo_dir, bare=True)\n+            logger.info(f\"Created repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Created repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def delete_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            shutil.rmtree(self.repo_path(repository_path, repo_name))\n+            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Deleted repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def clone_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        host = request.host\n+        response_data = {\n+            \"repository\": repo_name,\n+            \"clone_command\": f\"git clone {clone_url}\",\n+            \"clone_url\": clone_url\n+        }\n+        return web.json_response(response_data)\n+\n+    @require_auth\n+    async def push_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        commit_message = data.get('commit_message', 'Update from server')\n+        branch = data.get('branch', 'main')\n+        changes = data.get('changes', [])\n+        if not changes:\n+            return web.Response(text=\"No changes provided\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            for change in changes:\n+                file_path = os.path.join(temp_dir, change.get('file', ''))\n+                content = change.get('content', '')\n+                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n+                with open(file_path, 'w') as f:\n+                    f.write(content)\n+            temp_repo.git.add(A=True)\n+            if not temp_repo.config_reader().has_section('user'):\n+                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n+                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n+            temp_repo.index.commit(commit_message)\n+            origin = temp_repo.remote('origin')\n+            origin.push(refspec=f\"{branch}:{branch}\")\n+        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n+        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n+\n+    @require_auth\n+    async def pull_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            data = {}\n+        remote_url = data.get('remote_url')\n+        branch = data.get('branch', 'main')\n+        if not remote_url:\n+            return web.Response(text=\"Remote URL is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                remote_name = \"pull_source\"\n+                try:\n+                    remote = local_repo.create_remote(remote_name, remote_url)\n+                except git.GitCommandError:\n+                    remote = local_repo.remote(remote_name)\n+                    remote.set_url(remote_url)\n+                remote.fetch()\n+                local_repo.git.merge(f\"{remote_name}/{branch}\")\n+                origin = local_repo.remote('origin')\n+                origin.push()\n+                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n+                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def status_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                branches = [b.name for b in temp_repo.branches]\n+                active_branch = temp_repo.active_branch.name\n+                commits = []\n+                for commit in list(temp_repo.iter_commits(max_count=5)):\n+                    commits.append({\n+                        \"id\": commit.hexsha,\n+                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                        \"date\": commit.committed_datetime.isoformat(),\n+                        \"message\": commit.message\n+                    })\n+                files = []\n+                for root, dirs, filenames in os.walk(temp_dir):\n+                    if '.git' in root:\n+                        continue\n+                    for filename in filenames:\n+                        full_path = os.path.join(root, filename)\n+                        rel_path = os.path.relpath(full_path, temp_dir)\n+                        files.append(rel_path)\n+                status_info = {\n+                    \"repository\": repo_name,\n+                    \"branches\": branches,\n+                    \"active_branch\": active_branch,\n+                    \"recent_commits\": commits,\n+                    \"files\": files\n+                }\n+                return web.json_response(status_info)\n+            except Exception as e:\n+                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_repositories(self, request):\n+        username = request['username']\n+        try:\n+            repos = []\n+            user_dir = self.REPO_DIR\n+            if os.path.exists(user_dir):\n+                for item in os.listdir(user_dir):\n+                    item_path = os.path.join(user_dir, item)\n+                    if os.path.isdir(item_path) and item.endswith('.git'):\n+                        repos.append(item[:-4])\n+            if request.query.get('format') == 'json':\n+                return web.json_response({\"repositories\": repos})\n+            else:\n+                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n+        except Exception as e:\n+            logger.error(f\"Error listing repositories: {str(e)}\")\n+            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_branches(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            branches = [b.name for b in temp_repo.branches]\n+            return web.json_response({\"branches\": branches})\n+\n+    @require_auth\n+    async def create_branch(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        branch_name = data.get('branch_name')\n+        start_point = data.get('start_point', 'HEAD')\n+        if not branch_name:\n+            return web.Response(text=\"Branch name is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                temp_repo.git.branch(branch_name, start_point)\n+                temp_repo.git.push('origin', branch_name)\n+                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n+                return web.Response(text=f\"Created branch {branch_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def commit_log(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            limit = int(request.query.get('limit', 10))\n+            branch = request.query.get('branch', 'main')\n+        except ValueError:\n+            return web.Response(text=\"Invalid limit parameter\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                commits = []\n+                try:\n+                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n+                        commits.append({\n+                            \"id\": commit.hexsha,\n+                            \"short_id\": commit.hexsha[:7],\n+                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                            \"date\": commit.committed_datetime.isoformat(),\n+                            \"message\": commit.message.strip()\n+                        })\n+                except git.GitCommandError as e:\n+                    if \"unknown revision or path\" in str(e):\n+                        commits = []\n+                    else:\n+                        raise\n+                return web.json_response({\n+                    \"repository\": repo_name,\n+                    \"branch\": branch,\n+                    \"commits\": commits\n+                })\n+            except Exception as e:\n+                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def file_content(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        file_path = request.match_info.get('file_path', '')\n+        branch = request.query.get('branch', 'main')\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                try:\n+                    temp_repo.git.checkout(branch)\n+                except git.GitCommandError:\n+                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n+                file_full_path = os.path.join(temp_dir, file_path)\n+                if not os.path.exists(file_full_path):\n+                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n+                if os.path.isdir(file_full_path):\n+                    files = os.listdir(file_full_path)\n+                    return web.json_response({\n+                        \"repository\": repo_name,\n+                        \"path\": file_path,\n+                        \"type\": \"directory\",\n+                        \"contents\": files\n+                    })\n+                else:\n+                    try:\n+                        with open(file_full_path, 'r') as f:\n+                            content = f.read()\n+                        return web.Response(text=content)\n+                    except UnicodeDecodeError:\n+                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n+            except Exception as e:\n+                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def git_smart_http(self, request):\n+        username = request['username']\n+        repository_path = request['repository_path']\n+        path = request.path\n+        async def get_repository_path():\n+            req_path = path.lstrip('/')\n+            if req_path.endswith('/info/refs'):\n+                repo_name = req_path[:-len('/info/refs')]\n+            elif req_path.endswith('/git-upload-pack'):\n+                repo_name = req_path[:-len('/git-upload-pack')]\n+            elif req_path.endswith('/git-receive-pack'):\n+                repo_name = req_path[:-len('/git-receive-pack')]\n+            else:\n+                repo_name = req_path\n+            if repo_name.endswith('.git'):\n+                repo_name = repo_name[:-4]\n+            repo_name = repo_name.lstrip('git/')\n+            repo_dir = repository_path.joinpath(repo_name + '.git')\n+            logger.info(f\"Resolved repo path: {repo_dir}\")\n+            return repo_dir\n+        async def handle_info_refs(service):\n+            repo_path = await get_repository_path()\n+        \n+            logger.info(f\"handle_info_refs: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate()\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason='OK',\n+                    headers={\n+                        'Content-Type': f'application/x-{service}-advertisement',\n+                        'Cache-Control': 'no-cache'\n+                    }\n+                )\n+                await response.prepare(request)\n+                length = len(packet) + 4\n+                header = f\"{length:04x}\"\n+                await response.write(f\"{header}{packet}0000\".encode())\n+                await response.write(stdout)\n+                return response\n+            except Exception as e:\n+                logger.error(f\"Error handling info/refs: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        async def handle_service_rpc(service):\n+            repo_path = await get_repository_path()\n+            logger.info(f\"handle_service_rpc: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n+                return web.Response(text=\"Invalid Content-Type\", status=403)\n+            body = await request.read()\n+            cmd = [service, '--stateless-rpc', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdin=asyncio.subprocess.PIPE,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate(input=body)\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                return web.Response(\n+                    body=stdout,\n+                    content_type=f'application/x-{service}-result'\n+                )\n+            except Exception as e:\n+                logger.error(f\"Error handling service RPC: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        if request.method == 'GET' and path.endswith('/info/refs'):\n+            service = request.query.get('service')\n+            if service in ('git-upload-pack', 'git-receive-pack'):\n+                return await handle_info_refs(service)\n+            else:\n+                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n+        elif request.method == 'POST' and '/git-upload-pack' in path:\n+            return await handle_service_rpc('git-upload-pack')\n+        elif request.method == 'POST' and '/git-receive-pack' in path:\n+            return await handle_service_rpc('git-receive-pack')\n+        return web.Response(text=\"Not found\", status=404)\n+\n+if __name__ == '__main__':\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+        logger.info(\"Using uvloop for improved performance\")\n+    except ImportError:\n+        logger.info(\"uvloop not available, using standard event loop\")\n+    app = GitApplication()\n+    logger.info(\"Starting Git server on port 8080\")\n+    web.run_app(app, port=8080)"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Corrected repository deletion URL and added repository deletion functionality", "commit": "e06776d81d0fa40e4d9d5f57a6259df8db271372", "diff": "commit e06776d81d0fa40e4d9d5f57a6259df8db271372\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 08:24:43 2025 +0200\n\n    Performance.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 7e5e2c4..83dcc03 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -183,7 +183,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n-        self.router.add_view(\"/settings/repositories/respository/{name}/delete.html\", RepositoriesDeleteView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n         self.webdav = WebdavApplication(self)\n         self.git = GitApplication(self)\n         self.add_subapp(\"/webdav\", self.webdav)\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex 93bba77..120c232 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,9 +1,21 @@\n from snek.system.service import BaseService\n import asyncio \n+import shutil\n \n class RepositoryService(BaseService):\n     mapper_name = \"repository\"\n \n+    async def delete(self, user_uid, name):\n+        loop = asyncio.get_event_loop()\n+        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n+        try:\n+            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n+        except Exception as ex:\n+            print(ex)\n+\n+        await super().delete(user_uid=user_uid, name=name)\n+\n+\n     async def exists(self, user_uid, name, **kwargs):\n         kwargs[\"user_uid\"] = user_uid\n         kwargs[\"name\"] = name\n@@ -14,6 +26,9 @@ class RepositoryService(BaseService):\n         if not repository_path.exists():\n             repository_path.mkdir(parents=True)\n         repository_path = repository_path.joinpath(name)\n+        repository_path = str(repository_path)\n+        if not repository_path.endswith(\".git\"):\n+            repository_path += \".git\"\n         command = ['git', 'init', '--bare', repository_path]\n         process = await asyncio.subprocess.create_subprocess_exec(\n             *command,\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 3b6c7c6..4a59024 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -64,7 +64,7 @@ class BaseMapper:\n         for record in self.db.query(sql, *args):\n             yield dict(record)\n \n-    async def delete(self, kwargs=None) -> int:\n+    async def delete(self, **kwargs) -> int:\n         if not kwargs or not isinstance(kwargs, dict):\n             raise Exception(\"Can't execute delete with no filter.\")\n         return self.table.delete(**kwargs)\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nindex af2a906..5ba6c5b 100644\n--- a/src/snek/templates/settings/repositories/delete.html\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -1,20 +1,10 @@\n-<!DOCTYPE html>\n-<html lang=\"en\">\n-<head>\n-  <meta charset=\"UTF-8\">\n-  <title>Delete Repository</title>\n+{% extends 'settings/index.html' %}\n+\n+{% block header_text %}<h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>{% endblock %}\n+\n+{% block main %}\n   <style>\n-    body { font-family: sans-serif; margin: 2rem; }\n-    .container { max-width: 400px; margin: 0 auto; }\n-    .confirm-box {\n-      padding: 2rem;\n-      border-radius: 10px;\n-      text-align: center;\n-      margin-top: 2rem;\n-    }\n     .repo-name {\n       font-weight: bold;\n       font-size: 1.2rem;\n@@ -22,9 +12,9 @@\n     }\n     .actions {\n-      display: flex; gap: 1rem; justify-content: center; margin-top: 1.5rem;\n+      display: flex; gap: 1rem; justify-content: left; margin-top: 1.5rem;\n     }\n-    button, a {\n+    button {\n       border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n       font-size: 1rem; cursor: pointer;\n@@ -39,25 +29,15 @@\n       .confirm-box { padding: 1rem; }\n     }\n   </style>\n-</head>\n-<body>\n   <div class=\"container\">\n-    <h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>\n-    <div class=\"confirm-box\">\n-      <div>\n-      </div>\n       <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n-      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> my-first-repo</div>\n-      <form action=\"/repositories/delete\" method=\"post\" style=\"margin-top:1.5rem;\">\n-        <input type=\"hidden\" name=\"id\" value=\"1\">\n+      <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repository.name }}</div>\n+      <form method=\"post\" style=\"margin-top:1.5rem;\">\n+        <input type=\"hidden\" name=\"name\" value=\"{{ repository.name }}\">\n         <div class=\"actions\">\n           <button type=\"submit\"><i class=\"fa-solid fa-trash\"></i> Yes, delete</button>\n-          <a href=\"repositories.html\" class=\"cancel\"><i class=\"fa-solid fa-ban\"></i> Cancel</a>\n+          <button type=\"button\" onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Cancel</button>\n         </div>\n       </form>\n-    </div>\n   </div>\n-</body>\n-</html>\n-\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nindex a160736..a8d7c2f 100644\n--- a/src/snek/templates/settings/repositories/index.html\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -92,7 +92,7 @@\n           <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\n             <i class=\"fa-solid fa-pen\"></i> Edit\n           </a>\n-          <a class=\"button delete\" href=\"/settings/repositories/{{ repo.name }}/delete.html\">\n+          <a class=\"button delete\" href=\"/settings/repositories/repository/{{ repo.name }}/delete.html\">\n             <i class=\"fa-solid fa-trash\"></i> Delete\n           </a>\n         </div>\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 1b652e6..0c25c1d 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -61,8 +61,24 @@ class RepositoriesDeleteView(BaseFormView):\n     login_required = True\n \n     async def get(self):\n-        \n-        return await self.render_template(\"settings/repositories/delete.html\")\n+            \n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n \n+    async def post(self):\n+        user_uid = self.session.get(\"uid\")\n+        name = self.request.match_info[\"name\"]\n+        repository = await self.services.repository.get(\n+                user_uid=user_uid, name=name\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        await self.services.repository.delete(user_uid=user_uid, name=name)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Add uvloop dependency and fix repo path resolution", "commit": "adb59eff68e5c855fbce6f930db1ea13f59683f6", "diff": "commit adb59eff68e5c855fbce6f930db1ea13f59683f6\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 08:54:33 2025 +0200\n\n    Do it.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex 1a5ac0c..b6f1688 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,6 +33,7 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n+    \"uvloop\"\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 6955288..74dc884 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -382,10 +382,10 @@ class GitApplication(web.Application):\n                 repo_name = req_path\n             if repo_name.endswith('.git'):\n                 repo_name = repo_name[:-4]\n-            repo_name = repo_name.lstrip('git/')\n-            repo_dir = repository_path.joinpath(repo_name + '.git')\n+            repo_name = repo_name[4:]\n+            repo_dir = repository_path.joinpath(repo_name + \".git\")\n             logger.info(f\"Resolved repo path: {repo_dir}\")\n-            return repo_dir\n+            return repo_dir \n         async def handle_info_refs(service):\n             repo_path = await get_repository_path()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement basic authentication for git receive-pack requests", "commit": "3ae30f1f7645203a8e8c15bd298d802fffbd2334", "diff": "commit 3ae30f1f7645203a8e8c15bd298d802fffbd2334\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:09:33 2025 +0200\n\n    Do it.\n\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 74dc884..64bad65 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -41,9 +41,25 @@ class GitApplication(web.Application):\n             web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n         ])\n \n+\n     async def check_basic_auth(self, request):\n-        return \"retoor\", pathlib.Path(self.REPO_DIR)\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return None,None\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.parent.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        if not request[\"user\"]:\n+            return None,None\n+        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n+            request[\"user\"][\"uid\"]\n+        )\n+\n+        return request[\"user\"]['username'],request[\"repository_path\"]\n+\n \n     @staticmethod\n     def require_auth(handler):"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Increase client max size for uploads", "commit": "e5d155e1249f9df7c504a95c98171f7e4fe5d5a4", "diff": "commit e5d155e1249f9df7c504a95c98171f7e4fe5d5a4\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:18:55 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 83dcc03..e79b511 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -82,7 +82,7 @@ class Application(BaseApplication):\n         self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n         self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n         super().__init__(\n-            middlewares=middlewares, template_path=self.template_path, *args, **kwargs\n+            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n         )\n         session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n         self.tasks = asyncio.Queue()\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 64bad65..b7ccfe9 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -15,7 +15,7 @@ logger = logging.getLogger('git_server')\n class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n-        super().__init__(client_max_size=100*1024*1024)\n+        super().__init__(client_max_size=1024*1024*1024*5)\n         self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n         self.USERS = {\n             'x': 'x',"}
{"repo": ".", "date": "2025-05-09", "line": "refactor: Migrate from argparse to click and improve application startup", "commit": "7e8ae1632d19238954ca96657da1d3950ebd413c", "diff": "commit 7e8ae1632d19238954ca96657da1d3950ebd413c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 09:56:23 2025 +0200\n\n    Update.\n\ndiff --git a/Makefile b/Makefile\nindex 7a725b4..852efd4 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -5,17 +5,21 @@ GUNICORN=./.venv/bin/gunicorn\n GUNICORN_WORKERS = 1\n PORT = 8081\n \n-python:\n-\t$(PYTHON)\n+\n+\n+shell:\n+\t.venv/bin/snek shell\n \n dump:\n \t@$(PYTHON) -m snek.dump\n \n build:\n \n+serve: run \n+\n \n run:\n-\t.venv/usr/bin/snek\n+\t.venv/bin/snek serve\n \t\n install: ubuntu\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 198ea1e..35e56e3 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,40 +1,32 @@\n-import argparse\n+import click\n import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n+from IPython import start_ipython\n \n+@click.group()\n+def cli():\n+    pass\n \n-def main():\n-    \n+@cli.command()\n+@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n+@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def serve(port, host, db_path):\n     asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    \n-    parser = argparse.ArgumentParser(description=\"Run the web application.\")\n-    parser.add_argument(\n-        \"--port\",\n-        type=int,\n-        default=8081,\n-        help=\"Port to run the application on (default: 8081)\",\n-    )\n-    parser.add_argument(\n-        \"--host\",\n-        type=str,\n-        default=\"0.0.0.0\",\n-        help=\"Host to run the application on (default: 0.0.0.0)\",\n-    )\n-    parser.add_argument(\n-        \"--db_path\",\n-        type=str,\n-        default=\"snek.db\",\n-    )\n-\n-    args = parser.parse_args()\n-\n     web.run_app(\n     )\n \n+@cli.command()\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def shell(db_path):\n+    start_ipython(argv=[], user_ns={'app': app})\n+\n+def main():\n+    cli()\n \n if __name__ == \"__main__\":\n     main()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository view and related functionality", "commit": "95ad49df432195cb127f9fe695eac14678422b37", "diff": "commit 95ad49df432195cb127f9fe695eac14678422b37\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:08:46 2025 +0200\n\n    progress.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex e79b511..ceb7c9d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -38,6 +38,7 @@ from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n from snek.view.register import RegisterView\n from snek.view.rpc import RPCView\n+from snek.view.repository import RepositoryView\n from snek.view.search_user import SearchUserView\n from snek.view.settings.repositories import RepositoriesIndexView\n from snek.view.settings.repositories import RepositoriesCreateView\n@@ -164,6 +165,7 @@ class Application(BaseApplication):\n         self.router.add_view(\"/login.json\", LoginView)\n         self.router.add_view(\"/register.html\", RegisterView)\n         self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n         self.router.add_view(\"/drive.bin\", UploadView)\n         self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n         self.router.add_view(\"/search-user.html\", SearchUserView)\n@@ -180,6 +182,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex 6e28f84..e2b55b4 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -9,6 +9,7 @@ class DriveItemModel(BaseModel):\n     path = ModelField(name=\"path\", required=True, kind=str)\n     file_type = ModelField(name=\"file_type\", required=True, kind=str)\n     file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n \n     @property\n     def extension(self):\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 7b727f7..76e6d1c 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -7,6 +7,9 @@ from snek.system.service import BaseService\n class UserService(BaseService):\n     mapper_name = \"user\"\n \n+    async def get_by_username(self, username):\n+        return await self.get(username=username)\n+\n     async def search(self, query, **kwargs):\n         query = query.strip().lower()\n         if not query:\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex b7ccfe9..f8bfeb7 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -107,6 +107,7 @@ class GitApplication(web.Application):\n         error_response = self.check_repo_exists(repository_path, repo_name)\n         if error_response:\n             return error_response\n         try:\n             shutil.rmtree(self.repo_path(repository_path, repo_name))\n             logger.info(f\"Deleted repository: {repo_name} for user {username}\")\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex b05eb21..6b74792 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -15,8 +15,15 @@\n   <script src=\"/generic-form.js\" type=\"module\"></script>\n   <script src=\"/html-frame.js\" type=\"module\"></script>\n   <script src=\"/app.js\" type=\"module\"></script>\n+  <script src=\"/file-manager.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/base.css\">\n-\n+<link\n+    rel=\"stylesheet\"\n+    integrity=\"sha512-pBMV+3tn6+5xAZuhI6tyCmQkXh15riZDqGPxAx/U+FuiI5Dh3ZTjM23cZqQ25jJCfi8+ka9gzC2ukNkGkP/Aw==\"\n+    crossorigin=\"anonymous\"\n+    referrerpolicy=\"no-referrer\"\n+  />\n   <link rel=\"icon\" type=\"image/png\" href=\"/image/snek1.png\" sizes=\"32x32\">\n </head>\ndiff --git a/src/snek/templates/search_user.html b/src/snek/templates/search_user.html\nindex 4aad6eb..4f4b036 100644\n--- a/src/snek/templates/search_user.html\n+++ b/src/snek/templates/search_user.html\n@@ -5,7 +5,6 @@\n \n {% block main %}\n-\n <section class=\"chat-area\">\n     <div class=\"chat-header\">\n         <h2>Search user</h2>\ndiff --git a/src/snek/templates/settings/repositories/index.html b/src/snek/templates/settings/repositories/index.html\nindex a8d7c2f..115259e 100644\n--- a/src/snek/templates/settings/repositories/index.html\n+++ b/src/snek/templates/settings/repositories/index.html\n@@ -83,10 +83,10 @@\n </span>\n         </div>\n         <div class=\"actions\">\n-            <a class=\"button browse\" href=\"/repositories/{{ user.username }}/{{ repo.name }}\" target=\"_blank\">\n+            <a class=\"button browse\" href=\"/repository/{{ user.username.value }}/{{ repo.name }}\" target=\"_blank\">\n             <i class=\"fa-solid fa-folder-open\"></i> Browse\n           </a>\n-          <a class=\"button clone\" href=\"/repositories/{{ user.username }}/{{ repo.name }}/clone\">\n+          <a class=\"button clone\" href=\"/git/{{ user.uid.value }}/{{ repo.name.value }}\">\n             <i class=\"fa-solid fa-code-branch\"></i> Clone\n           </a>\n           <a class=\"button edit\" href=\"/settings/repositories/repository/{{ repo.name }}/update.html\">\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 853cdb2..e3c3343 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -3,18 +3,250 @@ from aiohttp import web\n from snek.system.view import BaseView\n \n \n+import os\n+import mimetypes\n+from aiohttp import web\n+from urllib.parse import unquote, quote\n+from datetime import datetime\n+\n+\n+\n+\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n+\"\"\"\n+from aiohttp import web\n+from pathlib import Path\n+import mimetypes, urllib.parse\n+\n+BASE_DIR   = Path(__file__).parent.resolve()\n+ROOT_DIR.mkdir(exist_ok=True)\n+ASSETS_DIR.mkdir(exist_ok=True)\n+\n+\n+def safe_resolve_path(rel: str) -> Path:\n+    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n+    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n+    if target == ROOT_DIR or ROOT_DIR in target.parents:\n+        return target\n+    raise FileNotFoundError(\"Unsafe path\")\n+\n+\n class DriveView(BaseView):\n+    async def get(self):\n+        rel = self.request.query.get(\"path\", \"\")\n+        offset = int(self.request.query.get(\"offset\", 0))\n+        limit  = int(self.request.query.get(\"limit\", 20))\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        if rel:\n+            target.joinpath(rel)\n+\n+        if not target.exists():\n+            return web.json_response({\"error\": \"Not found\"}, status=404)\n+\n+        if target.is_dir():\n+            entries = []\n+            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n+                item_path = (Path(rel) / p.name).as_posix()\n+                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n+                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n+                        if p.is_file() else None)\n+                entries.append({\n+                    \"name\": p.name,\n+                    \"type\": \"directory\" if p.is_dir() else \"file\",\n+                    \"mimetype\": mime,\n+                    \"size\": p.stat().st_size if p.is_file() else None,\n+                    \"path\": item_path,\n+                    \"url\": url,\n+                })\n+            import json \n+            total = len(entries)\n+            items = entries[offset:offset+limit]\n+            return web.json_response({\n+                \"items\": json.loads(json.dumps(items,default=str)),\n+                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n+            })\n+        \n+        with open(target, \"rb\") as f:\n+            content = f.read()\n+            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n+        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n+        return web.json_response({\n+            \"name\": target.name,\n+            \"type\": \"file\",\n+            \"mimetype\": mimetypes.guess_type(target.name)[0],\n+            \"size\": target.stat().st_size,\n+            \"path\": rel,\n+            \"url\": str(url),\n+        })\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+class DriveView222(BaseView):\n+    PAGE_SIZE = 20\n+\n+    async def base_path(self):\n+        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n+    async def get_full_path(self, rel_path):\n+        base_path = await self.base_path()\n+        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n+        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n+        if not full_path.startswith(os.path.abspath(base_path)):\n+            raise web.HTTPForbidden(reason=\"Invalid path\")\n+        return full_path\n+\n+    async def make_absolute_url(self, rel_path):\n+        rel_path = rel_path.lstrip(\"/\")\n+        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n+        return url\n+\n+    async def entry_details(self, dir_path, entry, parent_rel_path):\n+        entry_path = os.path.join(dir_path, entry)\n+        stat = os.stat(entry_path)\n+        is_dir = os.path.isdir(entry_path)\n+        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n+        size = stat.st_size if not is_dir else None\n+        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n+        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n+        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n+        return {\n+            \"name\": entry,\n+            \"type\": \"dir\" if is_dir else \"file\",\n+            \"mimetype\": mimetype,\n+            \"size\": size,\n+            \"created_at\": created_at,\n+            \"updated_at\": updated_at,\n+            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n+        }\n+\n+    async def get(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        page = int(self.request.query.get(\"page\", 1))\n+        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n+        abs_url = await self.make_absolute_url(rel_path)\n+\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+\n+        if os.path.isdir(full_path):\n+            entries = os.listdir(full_path)\n+            entries.sort()\n+            start = (page - 1) * page_size\n+            end = start + page_size\n+            paged_entries = entries[start:end]\n+            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n+            return web.json_response({\n+                \"path\": rel_path,\n+                \"absolute_url\": abs_url,\n+                \"entries\": details,\n+                \"total\": len(entries),\n+                \"page\": page,\n+                \"page_size\": page_size,\n+            })\n+        else:\n+            with open(full_path, \"rb\") as f:\n+                content = f.read()\n+            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n+            headers = {\"X-Absolute-Url\": abs_url}\n+            return web.Response(body=content, content_type=mimetype, headers=headers)\n+\n+    async def post(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if os.path.exists(full_path):\n+            raise web.HTTPConflict(reason=\"File or directory already exists\")\n+        data = await self.request.post()\n+        if data.get(\"type\") == \"dir\":\n+            os.makedirs(full_path)\n+            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            file_field = data.get(\"file\")\n+            if not file_field:\n+                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n+            with open(full_path, \"wb\") as f:\n+                f.write(file_field.file.read())\n+            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+    async def put(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"File not found\")\n+        if os.path.isdir(full_path):\n+            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n+        body = await self.request.read()\n+        with open(full_path, \"wb\") as f:\n+            f.write(body)\n+        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n+\n+    async def delete(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+        if os.path.isdir(full_path):\n+            os.rmdir(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            os.remove(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+\n+class DriveViewi2(BaseView):\n \n     login_required = True\n \n     async def get(self):\n \n         drive_uid = self.request.match_info.get(\"drive\")\n+            \n+\n+        before = self.request.query.get(\"before\")\n+        filters = {} \n+        if before:\n+            filters[\"created_at__lt\"] = before\n \n         if drive_uid:\n+            filters['drive_uid'] = drive_uid \n             drive = await self.services.drive.get(uid=drive_uid)\n             drive_items = []\n-            async for item in drive.items:\n+            \n+            \n+            \n+            async for item in self.services.drive_item.find(**filters):\n                 record = item.record\n                 record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n                 drive_items.append(record)\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 0c25c1d..093d229 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -15,8 +15,10 @@ class RepositoriesIndexView(BaseFormView):\n         repositories = []\n         async for repository in self.services.repository.find(user_uid=user_uid):\n             repositories.append(repository.record)\n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n \n-        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories})\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})"}
{"repo": ".", "date": "2025-05-09", "line": "'NoneType' object is not subscriptable", "commit": "17c6124a57a394c63427a0038e598fdb40560f15", "diff": "commit 17c6124a57a394c63427a0038e598fdb40560f15\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:19:29 2025 +0200\n\n    Minify.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex 8b13789..e69de29 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -1 +0,0 @@\n-\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 35e56e3..5d861d9 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,32 +1,21 @@\n-import click\n-import uvloop\n+_D='Database path for the application'\n+_C='snek.db'\n+_B='--db_path'\n+_A=True\n+import click,uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n-\n @click.group()\n-def cli():\n-    pass\n-\n+def cli():0\n @cli.command()\n-@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n-@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n-@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n-def serve(port, host, db_path):\n-    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    web.run_app(\n-    )\n-\n+@click.option('--port',default=8081,show_default=_A,help='Port to run the application on')\n+@click.option('--host',default='0.0.0.0',show_default=_A,help='Host to run the application on')\n+@click.option(_B,default=_C,show_default=_A,help=_D)\n @cli.command()\n-@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n-def shell(db_path):\n-    start_ipython(argv=[], user_ns={'app': app})\n-\n-def main():\n-    cli()\n-\n-if __name__ == \"__main__\":\n-    main()\n+@click.option(_B,default=_C,show_default=_A,help=_D)\n+def main():cli()\n+if __name__=='__main__':main()\n\\ No newline at end of file\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ceb7c9d..f5f1948 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,37 +1,31 @@\n-import asyncio\n-import logging\n-import pathlib\n-import time\n-import uuid\n-\n+_G='name'\n+_F='static'\n+_E='user'\n+_D=None\n+_C=True\n+_B='channel_uid'\n+_A='uid'\n+import asyncio,logging,pathlib,time,uuid\n from snek.view.threads import ThreadsView\n-\n logging.basicConfig(level=logging.DEBUG)\n-\n from concurrent.futures import ThreadPoolExecutor\n-\n from aiohttp import web\n-from aiohttp_session import (\n-    get_session as session_get,\n-    session_middleware,\n-    setup as session_setup,\n-)\n+from aiohttp_session import get_session as session_get,session_middleware,setup as session_setup\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n-\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import auth_middleware, cors_middleware\n+from snek.system.middleware import auth_middleware,cors_middleware\n from snek.system.profiler import profiler_handler\n-from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n-from snek.view.about import AboutHTMLView, AboutMDView\n+from snek.system.template import EmojiExtension,LinkifyExtension,PythonExtension\n+from snek.view.about import AboutHTMLView,AboutMDView\n from snek.view.avatar import AvatarView\n-from snek.view.docs import DocsHTMLView, DocsMDView\n+from snek.view.docs import DocsHTMLView,DocsMDView\n from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n@@ -48,275 +42,79 @@ from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n from snek.view.status import StatusView\n-from snek.view.terminal import TerminalSocketView, TerminalView\n+from snek.view.terminal import TerminalSocketView,TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-\n-SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n-\n-\n+SESSION_KEY=b'c79a0c5fda4b424189c427d28c9f7c34'\n @web.middleware\n-async def session_middleware(request, handler):\n-    setattr(request, \"session\", await session_get(request))\n-    response = await handler(request)\n-    return response\n-\n-\n+async def session_middleware(request,handler):A=request;setattr(A,'session',await session_get(A));B=await handler(A);return B\n @web.middleware\n-async def trailing_slash_middleware(request, handler):\n-    if request.path and not request.path.endswith(\"/\"):\n-        raise web.HTTPFound(request.path + \"/\")\n-    return await handler(request)\n-\n-\n+async def trailing_slash_middleware(request,handler):\n+\tA=request\n+\tif A.path and not A.path.endswith('/'):raise web.HTTPFound(A.path+'/')\n+\treturn await handler(A)\n class Application(BaseApplication):\n-\n-    def __init__(self, *args, **kwargs):\n-        middlewares = [\n-            cors_middleware,\n-            web.normalize_path_middleware(merge_slashes=True),\n-        ]\n-        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n-        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n-        super().__init__(\n-            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n-        )\n-        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n-        self.tasks = asyncio.Queue()\n-        self._middlewares.append(session_middleware)\n-        self._middlewares.append(auth_middleware)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-        self.jinja2_env.add_extension(LinkifyExtension)\n-        self.jinja2_env.add_extension(PythonExtension)\n-        self.jinja2_env.add_extension(EmojiExtension)\n-\n-        self.setup_router()\n-        self.executor = None\n-        self.cache = Cache(self)\n-        self.services = get_services(app=self)\n-        self.mappers = get_mappers(app=self)\n-        self.on_startup.append(self.prepare_asyncio)\n-        self.on_startup.append(self.prepare_database)\n-\n-    async def prepare_asyncio(self, app):\n-        app.executor = ThreadPoolExecutor(max_workers=200)\n-        app.loop.set_default_executor(self.executor)\n-\n-    async def create_task(self, task):\n-        await self.tasks.put(task)\n-\n-    async def task_runner(self):\n-        while True:\n-            task = await self.tasks.get()\n-            self.db.begin()\n-            try:\n-                task_start = time.time()\n-                await task\n-                task_end = time.time()\n-                print(f\"Task {task} took {task_end - task_start} seconds\")\n-                self.tasks.task_done()\n-            except Exception as ex:\n-                print(ex)\n-            self.db.commit()\n-\n-    async def prepare_database(self, app):\n-        self.db.query(\"PRAGMA journal_mode=WAL\")\n-        self.db.query(\"PRAGMA syncnorm=off\")\n-\n-        try:\n-            if not self.db[\"user\"].has_index(\"username\"):\n-                self.db[\"user\"].create_index(\"username\", unique=True)\n-            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n-                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n-            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n-                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n-        except:\n-            pass\n-\n-        await app.services.drive.prepare_all()\n-        self.loop.create_task(self.task_runner())\n-\n-    def setup_router(self):\n-        self.router.add_get(\"/\", IndexView)\n-        self.router.add_static(\n-            \"/\",\n-            pathlib.Path(__file__).parent.joinpath(\"static\"),\n-            name=\"static\",\n-            show_index=True,\n-        )\n-        self.router.add_view(\"/profiler.html\", profiler_handler)\n-        self.router.add_view(\"/about.html\", AboutHTMLView)\n-        self.router.add_view(\"/about.md\", AboutMDView)\n-        self.router.add_view(\"/logout.json\", LogoutView)\n-        self.router.add_view(\"/logout.html\", LogoutView)\n-        self.router.add_view(\"/docs.html\", DocsHTMLView)\n-        self.router.add_view(\"/docs.md\", DocsMDView)\n-        self.router.add_view(\"/status.json\", StatusView)\n-        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n-        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n-        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n-        self.router.add_view(\"/web.html\", WebView)\n-        self.router.add_view(\"/login.html\", LoginView)\n-        self.router.add_view(\"/login.json\", LoginView)\n-        self.router.add_view(\"/register.html\", RegisterView)\n-        self.router.add_view(\"/register.json\", RegisterView)\n-        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n-        self.router.add_view(\"/drive.bin\", UploadView)\n-        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n-        self.router.add_view(\"/search-user.html\", SearchUserView)\n-        self.router.add_view(\"/search-user.json\", SearchUserView)\n-        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n-        self.router.add_get(\"/http-get\", self.handle_http_get)\n-        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n-        self.router.add_get(\"/rpc.ws\", RPCView)\n-        self.router.add_view(\"/channel/{channel}.html\", WebView)\n-        self.router.add_view(\"/threads.html\", ThreadsView)\n-        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n-        self.router.add_view(\"/terminal.html\", TerminalView)\n-        self.router.add_view(\"/drive.json\", DriveView)\n-        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n-        self.router.add_view(\"/stats.json\", StatsView)\n-        self.router.add_view(\"/user/{user}.html\", UserView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n-        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n-        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n-        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n-        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n-        self.webdav = WebdavApplication(self)\n-        self.git = GitApplication(self)\n-        self.add_subapp(\"/webdav\", self.webdav)\n-        self.add_subapp(\"/git\",self.git)\n-        \n-    \n-    async def handle_test(self, request):\n-\n-        return await self.render_template(\n-            \"test.html\", request, context={\"name\": \"retoor\"}\n-        )\n-\n-    async def handle_http_get(self, request: web.Request):\n-        url = request.query.get(\"url\")\n-        content = await http.get(url)\n-        return web.Response(body=content)\n-\n-    async def handle_http_photo(self, request):\n-        url = request.query.get(\"url\")\n-        path = await http.create_site_photo(url)\n-        return web.Response(\n-            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n-        )\n-\n-    async def render_template(self, template, request, context=None):\n-        channels = []\n-        if not context:\n-            context = {}\n-        context[\"rid\"] = str(uuid.uuid4())\n-        if request.session.get(\"uid\"):\n-            async for subscribed_channel in self.services.channel_member.find(\n-                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n-            ):\n-                item = {}\n-                other_user = await self.services.channel_member.get_other_dm_user(\n-                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n-                )\n-                parent_object = await subscribed_channel.get_channel()\n-                last_message = await parent_object.get_last_message()\n-                color = None\n-                if last_message:\n-                    last_message_user = await last_message.get_user()\n-                    color = last_message_user[\"color\"]\n-                item[\"color\"] = color\n-                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n-                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n-                if other_user:\n-                    item[\"name\"] = other_user[\"nick\"]\n-                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                else:\n-                    item[\"name\"] = subscribed_channel[\"label\"]\n-                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n-                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n-\n-                channels.append(item)\n-\n-            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n-            if \"channels\" not in context:\n-                context[\"channels\"] = channels\n-            if \"user\" not in context:\n-                context[\"user\"] = await self.services.user.get(\n-                    request.session.get(\"uid\")\n-                )\n-\n-        self.template_path.joinpath(template)\n-\n-        await self.services.user.get_template_path(request.session.get(\"uid\"))\n-\n-        self.original_loader = self.jinja2_env.loader\n-\n-        self.jinja2_env.loader = await self.get_user_template_loader(\n-            request.session.get(\"uid\")\n-        )\n-\n-        rendered = await super().render_template(template, request, context)\n-\n-        self.jinja2_env.loader = self.original_loader\n-\n-        return rendered\n-\n-\n-    async def static_handler(self, request):\n-        file_name = request.match_info.get('filename', '')\n-\n-        paths = []\n-\n-        uid = request.session.get(\"uid\")\n-        if uid:\n-            user_static_path = await self.services.user.get_static_path(uid)\n-            if user_static_path:\n-                paths.append(user_static_path)\n-      \n-        for admin_uid in self.services.user.get_admin_uids():\n-            user_static_path = await self.services.user.get_static_path(admin_uid)\n-            if user_static_path:\n-                paths.append(user_static_path)\n-        \n-        paths.append(self.static_path)\n-\n-        for path in paths:\n-            if pathlib.Path(path).joinpath(file_name).exists():\n-                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n-        return web.HTTPNotFound()\n-\n-    async def get_user_template_loader(self, uid=None):\n-        template_paths = []\n-        for admin_uid in self.services.user.get_admin_uids():\n-            user_template_path = await self.services.user.get_template_path(admin_uid)\n-            if user_template_path:\n-                template_paths.append(user_template_path)\n-\n-        if uid:\n-            user_template_path = await self.services.user.get_template_path(uid)\n-            if user_template_path:\n-                template_paths.append(user_template_path)\n-\n-\n-        template_paths.append(self.template_path)\n-        return FileSystemLoader(template_paths)\n-\n-\n-\n-\n-async def main():\n-    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n-\n-\n-if __name__ == \"__main__\":\n-    asyncio.run(main())\n+\tdef __init__(A,*B,**C):D=[cors_middleware,web.normalize_path_middleware(merge_slashes=_C)];A.template_path=pathlib.Path(__file__).parent.joinpath('templates');A.static_path=pathlib.Path(__file__).parent.joinpath(_F);super().__init__(middlewares=D,template_path=A.template_path,client_max_size=5368709120*B,**C);session_setup(A,EncryptedCookieStorage(SESSION_KEY));A.tasks=asyncio.Queue();A._middlewares.append(session_middleware);A._middlewares.append(auth_middleware);A.jinja2_env.add_extension(MarkdownExtension);A.jinja2_env.add_extension(LinkifyExtension);A.jinja2_env.add_extension(PythonExtension);A.jinja2_env.add_extension(EmojiExtension);A.setup_router();A.executor=_D;A.cache=Cache(A);A.services=get_services(app=A);A.mappers=get_mappers(app=A);A.on_startup.append(A.prepare_asyncio);A.on_startup.append(A.prepare_database)\n+\tasync def prepare_asyncio(A,app):app.executor=ThreadPoolExecutor(max_workers=200);app.loop.set_default_executor(A.executor)\n+\tasync def create_task(A,task):await A.tasks.put(task)\n+\tasync def task_runner(A):\n+\t\twhile _C:\n+\t\t\tB=await A.tasks.get();A.db.begin()\n+\t\t\ttry:C=time.time();await B;D=time.time();print(f\"Task {B} took {D-C} seconds\");A.tasks.task_done()\n+\t\t\texcept Exception as E:print(E)\n+\t\t\tA.db.commit()\n+\tasync def prepare_database(A,app):\n+\t\tC='channel_message';D='channel_member';E='username';B='user_uid';A.db.query('PRAGMA journal_mode=WAL');A.db.query('PRAGMA syncnorm=off')\n+\t\ttry:\n+\t\t\tif not A.db[_E].has_index(E):A.db[_E].create_index(E,unique=_C)\n+\t\t\tif not A.db[D].has_index([_B,B]):A.db[D].create_index([_B,B])\n+\t\t\tif not A.db[C].has_index([_B,B]):A.db[C].create_index([_B,B])\n+\t\texcept:pass\n+\t\tawait app.services.drive.prepare_all();A.loop.create_task(A.task_runner())\n+\tdef setup_router(A):A.router.add_get('/',IndexView);A.router.add_static('/',pathlib.Path(__file__).parent.joinpath(_F),name=_F,show_index=_C);A.router.add_view('/profiler.html',profiler_handler);A.router.add_view('/about.html',AboutHTMLView);A.router.add_view('/about.md',AboutMDView);A.router.add_view('/logout.json',LogoutView);A.router.add_view('/logout.html',LogoutView);A.router.add_view('/docs.html',DocsHTMLView);A.router.add_view('/docs.md',DocsMDView);A.router.add_view('/status.json',StatusView);A.router.add_view('/settings/index.html',SettingsIndexView);A.router.add_view('/settings/profile.html',SettingsProfileView);A.router.add_view('/settings/profile.json',SettingsProfileView);A.router.add_view('/web.html',WebView);A.router.add_view('/login.html',LoginView);A.router.add_view('/login.json',LoginView);A.router.add_view('/register.html',RegisterView);A.router.add_view('/register.json',RegisterView);A.router.add_view('/drive/{rel_path:.*}',DriveView);A.router.add_view('/drive.bin',UploadView);A.router.add_view('/drive.bin/{uid}.{ext}',UploadView);A.router.add_view('/search-user.html',SearchUserView);A.router.add_view('/search-user.json',SearchUserView);A.router.add_view('/avatar/{uid}.svg',AvatarView);A.router.add_get('/http-get',A.handle_http_get);A.router.add_get('/http-photo',A.handle_http_photo);A.router.add_get('/rpc.ws',RPCView);A.router.add_view('/channel/{channel}.html',WebView);A.router.add_view('/threads.html',ThreadsView);A.router.add_view('/terminal.ws',TerminalSocketView);A.router.add_view('/terminal.html',TerminalView);A.router.add_view('/drive.json',DriveView);A.router.add_view('/drive/{drive}.json',DriveView);A.router.add_view('/stats.json',StatsView);A.router.add_view('/user/{user}.html',UserView);A.router.add_view('/repository/{username}/{repo_name}',RepositoryView);A.router.add_view('/repository/{username}/{repo_name}/{rel_path:.*}',RepositoryView);A.router.add_view('/settings/repositories/index.html',RepositoriesIndexView);A.router.add_view('/settings/repositories/create.html',RepositoriesCreateView);A.router.add_view('/settings/repositories/repository/{name}/update.html',RepositoriesUpdateView);A.router.add_view('/settings/repositories/repository/{name}/delete.html',RepositoriesDeleteView);A.webdav=WebdavApplication(A);A.git=GitApplication(A);A.add_subapp('/webdav',A.webdav);A.add_subapp('/git',A.git)\n+\tasync def handle_test(A,request):return await A.render_template('test.html',request,context={_G:'retoor'})\n+\tasync def handle_http_get(C,request):A=request.query.get('url');B=await http.get(A);return web.Response(body=B)\n+\tasync def handle_http_photo(C,request):A=request.query.get('url');B=await http.create_site_photo(A);return web.Response(body=B.read_bytes(),headers={'Content-Type':'image/png'})\n+\tasync def render_template(A,template,request,context=_D):\n+\t\tI='channels';J='new_count';K='color';L=template;F='last_message_on';D=request;C=context;G=[]\n+\t\tif not C:C={}\n+\t\tC['rid']=str(uuid.uuid4())\n+\t\tif D.session.get(_A):\n+\t\t\tasync for E in A.services.channel_member.find(user_uid=D.session.get(_A),deleted_at=_D,is_banned=False):\n+\t\t\t\tB={};M=await A.services.channel_member.get_other_dm_user(E[_B],D.session.get(_A));H=await E.get_channel();N=await H.get_last_message();O=_D\n+\t\t\t\tif N:P=await N.get_user();O=P[K]\n+\t\t\t\tB[K]=O;B[F]=H[F];B['is_private']=H['tag']=='dm'\n+\t\t\t\tif M:B[_G]=M['nick'];B[_A]=E[_B]\n+\t\t\t\telse:B[_G]=E['label'];B[_A]=E[_B]\n+\t\t\t\tB[J]=E[J];G.append(B)\n+\t\t\tG.sort(key=lambda x:x[F]or'',reverse=_C)\n+\t\t\tif I not in C:C[I]=G\n+\t\t\tif _E not in C:C[_E]=await A.services.user.get(D.session.get(_A))\n+\t\tA.template_path.joinpath(L);await A.services.user.get_template_path(D.session.get(_A));A.original_loader=A.jinja2_env.loader;A.jinja2_env.loader=await A.get_user_template_loader(D.session.get(_A));Q=await super().render_template(L,D,C);A.jinja2_env.loader=A.original_loader;return Q\n+\tasync def static_handler(B,request):\n+\t\tD=request;E=D.match_info.get('filename','');C=[];F=D.session.get(_A)\n+\t\tif F:\n+\t\t\tA=await B.services.user.get_static_path(F)\n+\t\t\tif A:C.append(A)\n+\t\tfor H in B.services.user.get_admin_uids():\n+\t\t\tA=await B.services.user.get_static_path(H)\n+\t\t\tif A:C.append(A)\n+\t\tC.append(B.static_path)\n+\t\tfor G in C:\n+\t\t\tif pathlib.Path(G).joinpath(E).exists():return web.FileResponse(pathlib.Path(G).joinpath(E))\n+\t\treturn web.HTTPNotFound()\n+\tasync def get_user_template_loader(B,uid=_D):\n+\t\tC=[]\n+\t\tfor D in B.services.user.get_admin_uids():\n+\t\t\tA=await B.services.user.get_template_path(D)\n+\t\t\tif A:C.append(A)\n+\t\tif uid:\n+\t\t\tA=await B.services.user.get_template_path(uid)\n+\t\t\tif A:C.append(A)\n+\t\tC.append(B.template_path);return FileSystemLoader(C)\n+async def main():await web._run_app(app,port=8081,host='0.0.0.0')\n+if __name__=='__main__':asyncio.run(main())\n\\ No newline at end of file\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex 50a4245..b47df44 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,43 +1,14 @@\n import pathlib\n-\n from aiohttp import web\n from app.app import Application as BaseApplication\n-\n from snek.system.markdown import MarkdownExtension\n-\n-\n class Application(BaseApplication):\n-\n-    def __init__(self, path=None, *args, **kwargs):\n-        self.path = pathlib.Path(path)\n-        template_path = self.path\n-\n-        super().__init__(template_path=template_path, *args, **kwargs)\n-        self.jinja2_env.add_extension(MarkdownExtension)\n-\n-        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n-\n-    async def handle_document(self, request):\n-        relative_path = request.match_info[\"tail\"].strip(\"/\")\n-        if relative_path == \"\":\n-            relative_path = \"index.html\"\n-        document_path = self.path.joinpath(relative_path)\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-        if document_path.is_dir():\n-            document_path = document_path.joinpath(\"index.html\")\n-        if not document_path.exists():\n-            return web.Response(\n-                status=404,\n-                body=b\"Resource is not found on this server.\",\n-                content_type=\"text/plain\",\n-            )\n-\n-        response = await self.render_template(\n-            str(document_path.relative_to(self.path)), request\n-        )\n-        return response\n+\tdef __init__(A,path=None,*B,**C):A.path=pathlib.Path(path);D=A.path;super().__init__(*B,template_path=D,**C);A.jinja2_env.add_extension(MarkdownExtension);A.router.add_get('/{tail:.*}',A.handle_document)\n+\tasync def handle_document(B,request):\n+\t\tD='text/plain';E=b'Resource is not found on this server.';F='index.html';G=request;C=G.match_info['tail'].strip('/')\n+\t\tif C=='':C=F\n+\t\tA=B.path.joinpath(C)\n+\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n+\t\tif A.is_dir():A=A.joinpath(F)\n+\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n+\t\tH=await B.render_template(str(A.relative_to(B.path)),G);return H\n\\ No newline at end of file\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex b254756..2d52196 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,39 +1,12 @@\n+_B='created_at'\n+_A='uid'\n import asyncio\n-\n from snek.app import app\n-\n-\n-async def fix_message(message):\n-    message = {\n-        \"uid\": message[\"uid\"],\n-        \"user_uid\": message[\"user_uid\"],\n-        \"text\": message[\"message\"],\n-        \"sent\": message[\"created_at\"],\n-    }\n-    user = await app.services.user.get(uid=message[\"user_uid\"])\n-    message[\"user\"] = user and user[\"username\"] or None\n-    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n-\n-\n+async def fix_message(message):C='user';D='text';B='user_uid';A=message;A={_A:A[_A],B:A[B],D:A['message'],'sent':A[_B]};E=await app.services.user.get(uid=A[B]);A[C]=E and E['username']or None;return(A[C]or'')+': '+(A[D]or'')\n async def dump_public_channels():\n-    result = []\n-    for channel in app.db[\"channel\"].find(\n-        is_private=False, is_listed=True, tag=\"public\"\n-    ):\n-        print(f\"Dumping channel: {channel['label']}.\")\n-        result += [\n-            await fix_message(record)\n-            for record in app.db[\"channel_message\"].find(\n-                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n-            )\n-        ]\n-        print(\"Dump succesfull!\")\n-    print(\"Converting to json.\")\n-    print(\"Converting succesful, now writing to dump.json\")\n-    with open(\"dump.txt\", \"w\") as f:\n-        f.write(\"\\n\\n\".join(result))\n-    print(\"Dump written to dump.json\")\n-\n-\n-if __name__ == \"__main__\":\n-    asyncio.run(dump_public_channels())\n+\tA=[]\n+\tfor B in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):print(f\"Dumping channel: {B[\"label\"]}.\");A+=[await fix_message(A)for A in app.db['channel_message'].find(channel_uid=B[_A],order_by=_B)];print('Dump succesfull!')\n+\tprint('Converting to json.');print('Converting succesful, now writing to dump.json')\n+\twith open('dump.txt','w')as C:C.write('\\n\\n'.join(A))\n+\tprint('Dump written to dump.json')\n+if __name__=='__main__':asyncio.run(dump_public_channels())\n\\ No newline at end of file\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex ef13d67..c0b8cfd 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,51 +1,14 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n+_B='username'\n+_A='password'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n class AuthField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.model.password.value and self.model.username.value:\n-            if not await self.app.services.user.validate_login(\n-                self.model.username.value, self.model.password.value\n-            ):\n-                return [\"Invalid username or password\"]\n-        return result\n-\n-\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=await super().errors\n+\t\tif A.model.password.value and A.model.username.value:\n+\t\t\tif not await A.app.services.user.validate_login(A.model.username.value,A.model.password.value):return['Invalid username or password']\n+\t\treturn B\n class LoginForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Login\")\n-\n-    username = AuthField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\",\n-    )\n-    password = AuthField(\n-        name=\"password\",\n-        required=True,\n-        min_length=1,\n-        type=\"password\",\n-        place_holder=\"Password\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n-    )\n-\n-    @property\n-    async def is_valid(self):\n-        return all(\n-            [\n-                self[\"username\"],\n-                self[\"password\"],\n-                not await self.username.errors,\n-                not await self.password.errors,\n-            ]\n-        )\n+\ttitle=HTMLElement(tag='h1',text='Login');username=AuthField(name=_B,required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');password=AuthField(name=_A,required=True,min_length=1,type=_A,place_holder='Password');action=FormButtonElement(name='action',value='submit',text='Login',type='button')\n+\t@property\n+\tasync def is_valid(self):A=self;return all([A[_B],A[_A],not await A.username.errors,not await A.password.errors])\n\\ No newline at end of file\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex b105696..a9f8c71 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,44 +1,10 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n+_B='password'\n+_A='Register'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n class UsernameField(FormInputElement):\n-\n-    @property\n-    async def errors(self):\n-        result = await super().errors\n-        if self.value and await self.app.services.user.count(username=self.value):\n-            result.append(\"Username is not available.\")\n-        return result\n-\n-\n-class RegisterForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Register\")\n-\n-    username = UsernameField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-]+$\",\n-        place_holder=\"Username\",\n-        type=\"text\",\n-    )\n-    email = FormInputElement(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-        place_holder=\"Email address\",\n-        type=\"email\",\n-    )\n-    password = FormInputElement(\n-        name=\"password\",\n-        required=True,\n-        min_length=1,\n-        type=\"password\",\n-        place_holder=\"Password\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n-    )\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=await super().errors\n+\t\tif A.value and await A.app.services.user.count(username=A.value):B.append('Username is not available.')\n+\t\treturn B\n+class RegisterForm(Form):title=HTMLElement(tag='h1',text=_A);username=UsernameField(name='username',required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');email=FormInputElement(name='email',required=False,regex='^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$',place_holder='Email address',type='email');password=FormInputElement(name=_B,required=True,min_length=1,type=_B,place_holder='Password');action=FormButtonElement(name='action',value='submit',text=_A,type='button')\n\\ No newline at end of file\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex 7e946b9..bf6de66 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -1,18 +1,2 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n-class SearchUserForm(Form):\n-\n-    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n-\n-    username = FormInputElement(\n-        name=\"username\",\n-        required=True,\n-        min_length=1,\n-        max_length=128,\n-        place_holder=\"Username\",\n-    )\n-\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n-    )\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+class SearchUserForm(Form):title=HTMLElement(tag='h1',text='Search user');username=FormInputElement(name='username',required=True,min_length=1,max_length=128,place_holder='Username');action=FormButtonElement(name='action',value='submit',text='Search',type='button')\n\\ No newline at end of file\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 836cd67..094c28e 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,25 +1,5 @@\n-from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n-\n-\n-class SettingsProfileForm(Form):\n-\n-    nick = FormInputElement(\n-        name=\"nick\",\n-        required=True,\n-        place_holder=\"Your Nickname\",\n-        min_length=1,\n-        max_length=20,\n-    )\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n-    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n-    profile = FormInputElement(\n-        name=\"profile\",\n-        place_holder=\"Tell about yourself.\",\n-        required=False,\n-        max_length=300,\n-    )\n-    action = FormButtonElement(\n-        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n-    )\n+_C='button'\n+_B='submit'\n+_A='action'\n+from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+class SettingsProfileForm(Form):nick=FormInputElement(name='nick',required=True,place_holder='Your Nickname',min_length=1,max_length=20);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C);title=HTMLElement(tag='h1',text='Profile');profile=FormInputElement(name='profile',place_holder='Tell about yourself.',required=False,max_length=300);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C)\n\\ No newline at end of file\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex 8583142..b51f326 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,3 +1,2 @@\n from snek.app import app\n-\n-application = app\n+application=app\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex ab7904f..917ab7d 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n@@ -10,24 +9,6 @@ from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_mappers(app=None):\n-    return Object(\n-        **{\n-            \"user\": UserMapper(app=app),\n-            \"channel_member\": ChannelMemberMapper(app=app),\n-            \"channel\": ChannelMapper(app=app),\n-            \"channel_message\": ChannelMessageMapper(app=app),\n-            \"notification\": NotificationMapper(app=app),\n-            \"drive_item\": DriveItemMapper(app=app),\n-            \"drive\": DriveMapper(app=app),\n-            \"user_property\": UserPropertyMapper(app=app),\n-            \"repository\": RepositoryMapper(app=app),\n-        }\n-    )\n-\n-\n-def get_mapper(name, app=None):\n-    return get_mappers(app=app)[name]\n+def get_mappers(app=None):A=app;return Object(**{'user':UserMapper(app=A),'channel_member':ChannelMemberMapper(app=A),'channel':ChannelMapper(app=A),'channel_message':ChannelMessageMapper(app=A),'notification':NotificationMapper(app=A),'drive_item':DriveItemMapper(app=A),'drive':DriveMapper(app=A),'user_property':UserPropertyMapper(app=A),'repository':RepositoryMapper(app=A)})\n+def get_mapper(name,app=None):return get_mappers(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nindex 6239dc8..d663d5b 100644\n--- a/src/snek/mapper/channel.py\n+++ b/src/snek/mapper/channel.py\n@@ -1,7 +1,3 @@\n from snek.model.channel import ChannelModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMapper(BaseMapper):\n-    table_name = \"channel\"\n-    model_class = ChannelModel\n+class ChannelMapper(BaseMapper):table_name='channel';model_class=ChannelModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nindex f0f62d6..b221d99 100644\n--- a/src/snek/mapper/channel_member.py\n+++ b/src/snek/mapper/channel_member.py\n@@ -1,7 +1,3 @@\n from snek.model.channel_member import ChannelMemberModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMemberMapper(BaseMapper):\n-    table_name = \"channel_member\"\n-    model_class = ChannelMemberModel\n+class ChannelMemberMapper(BaseMapper):table_name='channel_member';model_class=ChannelMemberModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex 35ccbe9..c27a9cb 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -1,7 +1,3 @@\n from snek.model.channel_message import ChannelMessageModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class ChannelMessageMapper(BaseMapper):\n-    model_class = ChannelMessageModel\n-    table_name = \"channel_message\"\n+class ChannelMessageMapper(BaseMapper):model_class=ChannelMessageModel;table_name='channel_message'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex c92c687..cac318a 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -1,7 +1,3 @@\n from snek.model.drive import DriveModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class DriveMapper(BaseMapper):\n-    table_name = \"drive\"\n-    model_class = DriveModel\n+class DriveMapper(BaseMapper):table_name='drive';model_class=DriveModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex 3d17a61..96676f6 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,8 +1,3 @@\n from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class DriveItemMapper(BaseMapper):\n-\n-    model_class = DriveItemModel\n-    table_name = \"drive_item\"\n+class DriveItemMapper(BaseMapper):model_class=DriveItemModel;table_name='drive_item'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex 9bd74b5..c2372ce 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,7 +1,3 @@\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class NotificationMapper(BaseMapper):\n-    table_name = \"notification\"\n-    model_class = NotificationModel\n+class NotificationMapper(BaseMapper):table_name='notification';model_class=NotificationModel\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nindex 1ac10d4..1c04ba3 100644\n--- a/src/snek/mapper/repository.py\n+++ b/src/snek/mapper/repository.py\n@@ -1,7 +1,3 @@\n from snek.model.repository import RepositoryModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class RepositoryMapper(BaseMapper):\n-    model_class = RepositoryModel\n-    table_name = \"repository\"\n+class RepositoryMapper(BaseMapper):model_class=RepositoryModel;table_name='repository'\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex e0df494..1df0eea 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,20 +1,7 @@\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n-\n-\n class UserMapper(BaseMapper):\n-    table_name = \"user\"\n-    model_class = UserModel\n-\n-    def get_admin_uids(self):\n-        try:\n-            return [\n-                user[\"uid\"]\n-                for user in self.db.query(\n-                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n-                    {\"is_admin\": True},\n-                )\n-            ]\n-        except Exception as ex:\n-            print(ex)\n-            return []\n+\ttable_name='user';model_class=UserModel\n+\tdef get_admin_uids(A):\n+\t\ttry:return[A['uid']for A in A.db.query('SELECT uid FROM user WHERE is_admin = :is_admin',{'is_admin':True})]\n+\t\texcept Exception as B:print(B);return[]\n\\ No newline at end of file\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nindex 7359f60..654e769 100644\n--- a/src/snek/mapper/user_property.py\n+++ b/src/snek/mapper/user_property.py\n@@ -1,7 +1,3 @@\n from snek.model.user_property import UserPropertyModel\n from snek.system.mapper import BaseMapper\n-\n-\n-class UserPropertyMapper(BaseMapper):\n-    table_name = \"user_property\"\n-    model_class = UserPropertyModel\n+class UserPropertyMapper(BaseMapper):table_name='user_property';model_class=UserPropertyModel\n\\ No newline at end of file\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 6399c89..bb7fb2a 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,9 +1,6 @@\n import functools\n-\n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n-\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.drive import DriveModel\n from snek.model.drive_item import DriveItemModel\n@@ -12,24 +9,6 @@ from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_models():\n-    return Object(\n-        **{\n-            \"user\": UserModel,\n-            \"channel_member\": ChannelMemberModel,\n-            \"channel\": ChannelModel,\n-            \"channel_message\": ChannelMessageModel,\n-            \"drive_item\": DriveItemModel,\n-            \"drive\": DriveModel,\n-            \"notification\": NotificationModel,\n-            \"user_property\": UserPropertyModel,\n-            \"repository\": RepositoryModel,\n-        }\n-    )\n-\n-\n-def get_model(name):\n-    return get_models()[name]\n+def get_models():return Object(**{'user':UserModel,'channel_member':ChannelMemberModel,'channel':ChannelModel,'channel_message':ChannelMessageModel,'drive_item':DriveItemModel,'drive':DriveModel,'notification':NotificationModel,'user_property':UserPropertyModel,'repository':RepositoryModel})\n+def get_model(name):return get_models()[name]\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 0a90c39..939d658 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,30 +1,12 @@\n+_C='uid'\n+_B=False\n+_A=True\n from snek.model.channel_message import ChannelMessageModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class ChannelModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True, kind=str)\n-    description = ModelField(name=\"description\", required=False, kind=str)\n-    tag = ModelField(name=\"tag\", required=False, kind=str)\n-    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n-    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n-    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n-    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n-    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n-\n-    async def get_last_message(self) -> ChannelMessageModel:\n-        try:\n-            async for model in self.app.services.channel_message.query(\n-                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n-                {\"channel_uid\": self[\"uid\"]},\n-            ):\n-\n-                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n-        except:\n-            pass\n-        return None\n-\n-    async def get_members(self):\n-        return await self.app.services.channel_member.find(\n-            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n-        )\n+\tlabel=ModelField(name='label',required=_A,kind=str);description=ModelField(name='description',required=_B,kind=str);tag=ModelField(name='tag',required=_B,kind=str);created_by_uid=ModelField(name='created_by_uid',required=_A,kind=str);is_private=ModelField(name='is_private',required=_A,kind=bool,value=_B);is_listed=ModelField(name='is_listed',required=_A,kind=bool,value=_A);index=ModelField(name='index',required=_A,kind=int,value=1000);last_message_on=ModelField(name='last_message_on',required=_B,kind=str)\n+\tasync def get_last_message(A):\n+\t\ttry:\n+\t\t\tasync for B in A.app.services.channel_message.query('SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1',{'channel_uid':A[_C]}):return await A.app.services.channel_message.get(uid=B[_C])\n+\t\texcept:pass\n+\tasync def get_members(A):return await A.app.services.channel_member.find(channel_uid=A[_C],deleted_at=None,is_banned=_B)\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 54b0418..09b7e91 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,41 +1,19 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+_D='channel_uid'\n+_C='user_uid'\n+_B=False\n+_A=True\n+from snek.system.model import BaseModel,ModelField\n class ChannelMemberModel(BaseModel):\n-    label = ModelField(name=\"label\", required=True, kind=str)\n-    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    is_moderator = ModelField(\n-        name=\"is_moderator\", required=True, kind=bool, value=False\n-    )\n-    is_read_only = ModelField(\n-        name=\"is_read_only\", required=True, kind=bool, value=False\n-    )\n-    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n-    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n-    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n-\n-    async def get_user(self):\n-        return await self.app.services.user.get(uid=self[\"user_uid\"])\n-\n-    async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n-\n-    async def get_name(self):\n-        channel = await self.get_channel()\n-        if channel[\"tag\"] == \"dm\":\n-            user = await self.get_other_dm_user()\n-            return user[\"nick\"]\n-        return channel[\"name\"] or self[\"label\"]\n-\n-    async def get_other_dm_user(self):\n-        channel = await self.get_channel()\n-        if channel[\"tag\"] != \"dm\":\n-            return None\n-\n-        async for model in self.app.services.channel_member.find(\n-            channel_uid=channel[\"uid\"]\n-        ):\n-            if model[\"uid\"] != self[\"uid\"]:\n-                return await self.app.services.user.get(uid=model[\"user_uid\"])\n-        return await self.get_user()\n+\tlabel=ModelField(name='label',required=_A,kind=str);channel_uid=ModelField(name=_D,required=_A,kind=str);user_uid=ModelField(name=_C,required=_A,kind=str);is_moderator=ModelField(name='is_moderator',required=_A,kind=bool,value=_B);is_read_only=ModelField(name='is_read_only',required=_A,kind=bool,value=_B);is_muted=ModelField(name='is_muted',required=_A,kind=bool,value=_B);is_banned=ModelField(name='is_banned',required=_A,kind=bool,value=_B);new_count=ModelField(name='new_count',required=_B,kind=int,value=0)\n+\tasync def get_user(A):return await A.app.services.user.get(uid=A[_C])\n+\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_D])\n+\tasync def get_name(A):\n+\t\tB=await A.get_channel()\n+\t\tif B['tag']=='dm':C=await A.get_other_dm_user();return C['nick']\n+\t\treturn B['name']or A['label']\n+\tasync def get_other_dm_user(A):\n+\t\tB='uid';C=await A.get_channel()\n+\t\tif C['tag']!='dm':return\n+\t\tasync for D in A.app.services.channel_member.find(channel_uid=C[B]):\n+\t\t\tif D[B]!=A[B]:return await A.app.services.user.get(uid=D[_C])\n+\t\treturn await A.get_user()\n\\ No newline at end of file\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 524a8a4..0677d7c 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,15 +1,8 @@\n+_B='user_uid'\n+_A='channel_uid'\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class ChannelMessageModel(BaseModel):\n-    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    message = ModelField(name=\"message\", required=True, kind=str)\n-    html = ModelField(name=\"html\", required=False, kind=str)\n-\n-    async def get_user(self) -> UserModel:\n-        return await self.app.services.user.get(uid=self[\"user_uid\"])\n-\n-    async def get_channel(self):\n-        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n+\tchannel_uid=ModelField(name=_A,required=True,kind=str);user_uid=ModelField(name=_B,required=True,kind=str);message=ModelField(name='message',required=True,kind=str);html=ModelField(name='html',required=False,kind=str)\n+\tasync def get_user(A):return await A.app.services.user.get(uid=A[_B])\n+\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_A])\n\\ No newline at end of file\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex df17d0f..62a2846 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,14 +1,6 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class DriveModel(BaseModel):\n-\n-    user_uid = ModelField(name=\"user_uid\", required=True)\n-    name = ModelField(name=\"name\", required=False, type=str)\n-\n-    @property\n-    async def items(self):\n-        async for drive_item in self.app.services.drive_item.find(\n-            drive_uid=self[\"uid\"]\n-        ):\n-            yield drive_item\n+\tuser_uid=ModelField(name='user_uid',required=True);name=ModelField(name='name',required=False,type=str)\n+\t@property\n+\tasync def items(self):\n+\t\tasync for A in self.app.services.drive_item.find(drive_uid=self['uid']):yield A\n\\ No newline at end of file\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex e2b55b4..a4427f3 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,21 +1,10 @@\n+_B='name'\n+_A=True\n import mimetypes\n-\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+from snek.system.model import BaseModel,ModelField\n class DriveItemModel(BaseModel):\n-    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n-    name = ModelField(name=\"name\", required=True, kind=str)\n-    path = ModelField(name=\"path\", required=True, kind=str)\n-    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n-    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n-    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n-\n-    @property\n-    def extension(self):\n-        return self[\"name\"].split(\".\")[-1]\n-\n-    @property\n-    def mime_type(self):\n-        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n-        return mimetype\n+\tdrive_uid=ModelField(name='drive_uid',required=_A,kind=str);name=ModelField(name=_B,required=_A,kind=str);path=ModelField(name='path',required=_A,kind=str);file_type=ModelField(name='file_type',required=_A,kind=str);file_size=ModelField(name='file_size',required=_A,kind=int);is_available=ModelField(name='is_available',required=_A,kind=bool,initial_value=_A)\n+\t@property\n+\tdef extension(self):return self[_B].split('.')[-1]\n+\t@property\n+\tdef mime_type(self):A,B=mimetypes.guess_type(self[_B]);return A\n\\ No newline at end of file\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex 6a12328..a8453eb 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,9 +1,3 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class NotificationModel(BaseModel):\n-    object_uid = ModelField(name=\"object_uid\", required=True)\n-    object_type = ModelField(name=\"object_type\", required=True)\n-    message = ModelField(name=\"message\", required=True)\n-    user_uid = ModelField(name=\"user_uid\", required=True)\n-    read_at = ModelField(name=\"is_read\", required=True)\n+_A=True\n+from snek.system.model import BaseModel,ModelField\n+class NotificationModel(BaseModel):object_uid=ModelField(name='object_uid',required=_A);object_type=ModelField(name='object_type',required=_A);message=ModelField(name='message',required=_A);user_uid=ModelField(name='user_uid',required=_A);read_at=ModelField(name='is_read',required=_A)\n\\ No newline at end of file\ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nindex 598cbb2..40ef94a 100644\n--- a/src/snek/model/repository.py\n+++ b/src/snek/model/repository.py\n@@ -1,14 +1,3 @@\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class RepositoryModel(BaseModel):\n-\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    \n-    name = ModelField(name=\"name\", required=True, kind=str)\n-\n-    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n-\n-\n-    \n+from snek.system.model import BaseModel,ModelField\n+class RepositoryModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);is_private=ModelField(name='is_private',required=False,kind=bool)\n\\ No newline at end of file\ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 9869456..8572402 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,60 +1,17 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n+_D='^[a-zA-Z0-9_-+/]+$'\n+_C=False\n+_B=True\n+_A='uid'\n+from snek.system.model import BaseModel,ModelField\n class UserModel(BaseModel):\n-\n-    username = ModelField(\n-        name=\"username\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n-    )\n-    nick = ModelField(\n-        name=\"nick\",\n-        required=True,\n-        min_length=2,\n-        max_length=20,\n-        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n-    )\n-    color = ModelField(\n-    )\n-    email = ModelField(\n-        name=\"email\",\n-        required=False,\n-        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n-    )\n-    password = ModelField(name=\"password\", required=True, min_length=1)\n-\n-    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n-\n-    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n-\n-    async def get_property(self, name):\n-        prop = await self.app.services.user_property.find_one(\n-            user_uid=self[\"uid\"], name=name\n-        )\n-        if prop:\n-            return prop[\"value\"]\n-\n-    async def has_property(self, name):\n-        return await self.app.services.user_property.exists(\n-            user_uid=self[\"uid\"], name=name\n-        )\n-\n-    async def set_property(self, name, value):\n-        if not await self.has_property(name):\n-            await self.app.services.user_property.insert(\n-                user_uid=self[\"uid\"], name=name, value=value\n-            )\n-        else:\n-            await self.app.services.user_property.update(\n-                user_uid=self[\"uid\"], name=name, value=value\n-            )\n-\n-    async def get_channel_members(self):\n-        async for channel_member in self.app.services.channel_member.find(\n-            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n-        ):\n-            yield channel_member\n+\tasync def get_property(A,name):\n+\t\tB=await A.app.services.user_property.find_one(user_uid=A[_A],name=name)\n+\t\tif B:return B['value']\n+\tasync def has_property(A,name):return await A.app.services.user_property.exists(user_uid=A[_A],name=name)\n+\tasync def set_property(A,name,value):\n+\t\tC=value;B=name\n+\t\tif not await A.has_property(B):await A.app.services.user_property.insert(user_uid=A[_A],name=B,value=C)\n+\t\telse:await A.app.services.user_property.update(user_uid=A[_A],name=B,value=C)\n+\tasync def get_channel_members(A):\n+\t\tasync for B in A.app.services.channel_member.find(user_uid=A[_A],is_banned=_C,deleted_at=None):yield B\n\\ No newline at end of file\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 1231423..77e5b25 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,7 +1,2 @@\n-from snek.system.model import BaseModel, ModelField\n-\n-\n-class UserPropertyModel(BaseModel):\n-    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n-    name = ModelField(name=\"name\", required=True, kind=str)\n-    value = ModelField(name=\"path\", required=True, kind=str)\n+from snek.system.model import BaseModel,ModelField\n+class UserPropertyModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);value=ModelField(name='path',required=True,kind=str)\n\\ No newline at end of file\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex be356dc..583ef6c 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,5 +1,4 @@\n import functools\n-\n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n@@ -13,27 +12,6 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n-\n-\n @functools.cache\n-def get_services(app):\n-    return Object(\n-        **{\n-            \"user\": UserService(app=app),\n-            \"channel_member\": ChannelMemberService(app=app),\n-            \"channel\": ChannelService(app=app),\n-            \"channel_message\": ChannelMessageService(app=app),\n-            \"chat\": ChatService(app=app),\n-            \"socket\": SocketService(app=app),\n-            \"notification\": NotificationService(app=app),\n-            \"util\": UtilService(app=app),\n-            \"drive\": DriveService(app=app),\n-            \"drive_item\": DriveItemService(app=app),\n-            \"user_property\": UserPropertyService(app=app),\n-            \"repository\": RepositoryService(app=app),\n-        }\n-    )\n-\n-\n-def get_service(name, app=None):\n-    return get_services(app=app)[name]\n+def get_services(app):A=app;return Object(**{'user':UserService(app=A),'channel_member':ChannelMemberService(app=A),'channel':ChannelService(app=A),'channel_message':ChannelMessageService(app=A),'chat':ChatService(app=A),'socket':SocketService(app=A),'notification':NotificationService(app=A),'util':UtilService(app=A),'drive':DriveService(app=A),'drive_item':DriveItemService(app=A),'user_property':UserPropertyService(app=A),'repository':RepositoryService(app=A)})\n+def get_service(name,app=None):return get_services(app=app)[name]\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex b90e66f..8c39f6e 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,108 +1,49 @@\n+_F='channel_uid'\n+_E='public'\n+_D=True\n+_C='uid'\n+_B=None\n+_A=False\n from datetime import datetime\n-\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class ChannelService(BaseService):\n-    mapper_name = \"channel\"\n-\n-    async def get(self, uid=None, **kwargs):\n-        if uid:\n-            kwargs[\"uid\"] = uid\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            del kwargs[\"uid\"]\n-            kwargs[\"name\"] = uid\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            result = await super().get(**kwargs)\n-            if result:\n-                return result\n-            return None\n-        return await super().get(**kwargs)\n-\n-    async def create(\n-        self,\n-        label,\n-        created_by_uid,\n-        description=None,\n-        tag=None,\n-        is_private=False,\n-        is_listed=True,\n-    ):\n-        count = await self.count(deleted_at=None)\n-        if not tag and not count:\n-            tag = \"public\"\n-        model = await self.new()\n-        model[\"label\"] = label\n-        model[\"description\"] = description\n-        model[\"tag\"] = tag\n-        model[\"created_by_uid\"] = created_by_uid\n-        model[\"is_private\"] = is_private\n-        model[\"is_listed\"] = is_listed\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel: {model.errors}.\")\n-\n-    async def get_dm(self, user1, user2):\n-        channel_member = await self.services.channel_member.get_dm(user1, user2)\n-        if channel_member:\n-            return await self.get(uid=channel_member[\"channel_uid\"])\n-        channel = await self.create(\"DM\", user1, tag=\"dm\")\n-        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n-        return channel\n-\n-    async def get_users(self, channel_uid):\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_uid,\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n-            if user:\n-                yield user\n-\n-    async def get_online_users(self, channel_uid):\n-        async for user in self.get_users(channel_uid):\n-            if not user[\"last_ping\"]:\n-                continue\n-\n-            if (\n-                datetime.fromisoformat(now())\n-                - datetime.fromisoformat(user[\"last_ping\"])\n-            ).total_seconds() < 20:\n-                yield user\n-\n-    async def get_for_user(self, user_uid):\n-        async for channel_member in self.services.channel_member.find(\n-            user_uid=user_uid,\n-            is_banned=False,\n-            deleted_at=None,\n-        ):\n-            channel = await self.get(uid=channel_member[\"channel_uid\"])\n-            yield channel\n-\n-    async def ensure_public_channel(self, created_by_uid):\n-        model = await self.get(is_listed=True, tag=\"public\")\n-        is_moderator = False\n-        if not model:\n-            is_moderator = True\n-            model = await self.create(\n-                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n-            )\n-        await self.app.services.channel_member.create(\n-            model[\"uid\"],\n-            created_by_uid,\n-            is_moderator=is_moderator,\n-            is_read_only=False,\n-            is_muted=False,\n-            is_banned=False,\n-        )\n-        return model\n+\tmapper_name='channel'\n+\tasync def get(E,uid=_B,**A):\n+\t\tD='name';C=uid\n+\t\tif C:\n+\t\t\tA[_C]=C;B=await super().get(**A)\n+\t\t\tif B:return B\n+\t\t\tdel A[_C];A[D]=C;B=await super().get(**A)\n+\t\t\tif B:return B\n+\t\t\tif B:return B\n+\t\t\treturn\n+\t\treturn await super().get(**A)\n+\tasync def create(C,label,created_by_uid,description=_B,tag=_B,is_private=_A,is_listed=_D):\n+\t\tE=is_listed;D=tag;B=label\n+\t\tF=await C.count(deleted_at=_B)\n+\t\tif not D and not F:D=_E\n+\t\tA=await C.new();A['label']=B;A['description']=description;A['tag']=D;A['created_by_uid']=created_by_uid;A['is_private']=is_private;A['is_listed']=E\n+\t\tif await C.save(A):return A\n+\t\traise Exception(f\"Failed to create channel: {A.errors}.\")\n+\tasync def get_dm(A,user1,user2):\n+\t\tC=user2;B=user1;D=await A.services.channel_member.get_dm(B,C)\n+\t\tif D:return await A.get(uid=D[_F])\n+\t\tE=await A.create('DM',B,tag='dm');await A.services.channel_member.create_dm(E[_C],B,C);return E\n+\tasync def get_users(A,channel_uid):\n+\t\tasync for C in A.services.channel_member.find(channel_uid=channel_uid,is_banned=_A,is_muted=_A,deleted_at=_B):\n+\t\t\tB=await A.services.user.get(uid=C['user_uid'])\n+\t\t\tif B:yield B\n+\tasync def get_online_users(C,channel_uid):\n+\t\tB='last_ping'\n+\t\tasync for A in C.get_users(channel_uid):\n+\t\t\tif not A[B]:continue\n+\t\t\tif(datetime.fromisoformat(now())-datetime.fromisoformat(A[B])).total_seconds()<20:yield A\n+\tasync def get_for_user(A,user_uid):\n+\t\tasync for B in A.services.channel_member.find(user_uid=user_uid,is_banned=_A,deleted_at=_B):C=await A.get(uid=B[_F]);yield C\n+\tasync def ensure_public_channel(B,created_by_uid):\n+\t\tC=created_by_uid;A=await B.get(is_listed=_D,tag=_E);D=_A\n+\t\tif not A:D=_D;A=await B.create(_E,created_by_uid=C,is_listed=_D,tag=_E)\n+\t\tawait B.app.services.channel_member.create(A[_C],C,is_moderator=D,is_read_only=_A,is_muted=_A,is_banned=_A);return A\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex df96786..cd3a62b 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,74 +1,28 @@\n+_C='user_uid'\n+_B='channel_uid'\n+_A=False\n from snek.system.service import BaseService\n-\n-\n class ChannelMemberService(BaseService):\n-\n-    mapper_name = \"channel_member\"\n-\n-    async def mark_as_read(self, channel_uid, user_uid):\n-        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel_member[\"new_count\"] = 0\n-        return await self.save(channel_member)\n-\n-    async def get_user_uids(self, channel_uid):\n-        async for model in self.mapper.query(\n-            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n-            {\"channel_uid\": channel_uid},\n-        ):\n-            yield model[\"user_uid\"]\n-\n-    async def create(\n-        self,\n-        channel_uid,\n-        user_uid,\n-        is_moderator=False,\n-        is_read_only=False,\n-        is_muted=False,\n-        is_banned=False,\n-    ):\n-        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        if model:\n-            if model[\"is_banned\"]:\n-                return False\n-            return model\n-        model = await self.new()\n-        channel = await self.services.channel.get(uid=channel_uid)\n-        model[\"label\"] = channel[\"label\"]\n-        model[\"channel_uid\"] = channel_uid\n-        model[\"user_uid\"] = user_uid\n-        model[\"is_moderator\"] = is_moderator\n-        model[\"is_read_only\"] = is_read_only\n-        model[\"is_muted\"] = is_muted\n-        model[\"is_banned\"] = is_banned\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n-\n-    async def get_dm(self, from_user, to_user):\n-        async for model in self.query(\n-            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n-            {\"from_user\": from_user, \"to_user\": to_user},\n-        ):\n-            return model\n-        if not from_user == to_user:\n-            return None\n-        async for model in self.query(\n-            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n-            {\"from_user\": from_user, \"to_user\": to_user},\n-        ):\n-\n-            return model\n-\n-    async def get_other_dm_user(self, channel_uid, user_uid):\n-        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n-        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n-        if channel[\"tag\"] != \"dm\":\n-            return None\n-        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n-            if model[\"uid\"] != channel_member[\"uid\"]:\n-                return await self.services.user.get(uid=model[\"user_uid\"])\n-\n-    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n-        result = await self.create(channel_uid, from_user_uid)\n-        await self.create(channel_uid, to_user_uid)\n-        return result\n+\tmapper_name='channel_member'\n+\tasync def mark_as_read(A,channel_uid,user_uid):B=await A.get(channel_uid=channel_uid,user_uid=user_uid);B['new_count']=0;return await A.save(B)\n+\tasync def get_user_uids(A,channel_uid):\n+\t\tasync for B in A.mapper.query('SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid',{_B:channel_uid}):yield B[_C]\n+\tasync def create(B,channel_uid,user_uid,is_moderator=_A,is_read_only=_A,is_muted=_A,is_banned=_A):\n+\t\tD='label';E='is_banned';F=user_uid;C=channel_uid;A=await B.get(channel_uid=C,user_uid=F)\n+\t\tif A:\n+\t\t\tif A[E]:return _A\n+\t\t\treturn A\n+\t\tA=await B.new();G=await B.services.channel.get(uid=C);A[D]=G[D];A[_B]=C;A[_C]=F;A['is_moderator']=is_moderator;A['is_read_only']=is_read_only;A['is_muted']=is_muted;A[E]=is_banned\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create channel member: {A.errors}.\")\n+\tasync def get_dm(D,from_user,to_user):\n+\t\tE='to_user';F='from_user';A=to_user;B=from_user\n+\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n+\t\tif not B==A:return\n+\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n+\tasync def get_other_dm_user(A,channel_uid,user_uid):\n+\t\tB='uid';C=channel_uid;D=await A.get(channel_uid=C,user_uid=user_uid);F=await A.services.channel.get(uid=D[_B])\n+\t\tif F['tag']!='dm':return\n+\t\tasync for E in A.services.channel_member.find(channel_uid=C):\n+\t\t\tif E[B]!=D[B]:return await A.services.user.get(uid=E[_C])\n+\tasync def create_dm(A,channel_uid,from_user_uid,to_user_uid):B=channel_uid;C=await A.create(B,from_user_uid);await A.create(B,to_user_uid);return C\n\\ No newline at end of file\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex f8a000f..1841024 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,93 +1,33 @@\n+_I='user_nick'\n+_H='created_at'\n+_G='html'\n+_F='uid'\n+_E='message'\n+_D='color'\n+_C='username'\n+_B='user_uid'\n+_A='channel_uid'\n from snek.system.service import BaseService\n-\n-\n class ChannelMessageService(BaseService):\n-    mapper_name = \"channel_message\"\n-\n-    async def create(self, channel_uid, user_uid, message):\n-        model = await self.new()\n-\n-        model[\"channel_uid\"] = channel_uid\n-        model[\"user_uid\"] = user_uid\n-        model[\"message\"] = message\n-\n-        context = {}\n-\n-        record = model.record\n-        context.update(record)\n-        user = await self.app.services.user.get(uid=user_uid)\n-        context.update(\n-            {\n-                \"user_uid\": user[\"uid\"],\n-                \"username\": user[\"username\"],\n-                \"user_nick\": user[\"nick\"],\n-                \"color\": user[\"color\"],\n-            }\n-        )\n-        try:\n-            template = self.app.jinja2_env.get_template(\"message.html\")\n-            model[\"html\"] = template.render(**context)\n-        except Exception as ex:\n-            print(ex, flush=True)\n-\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n-\n-    async def to_extended_dict(self, message):\n-        user = await self.services.user.get(uid=message[\"user_uid\"])\n-        if not user:\n-            return {}\n-        return {\n-            \"uid\": message[\"uid\"],\n-            \"color\": user[\"color\"],\n-            \"user_uid\": message[\"user_uid\"],\n-            \"channel_uid\": message[\"channel_uid\"],\n-            \"user_nick\": user[\"nick\"],\n-            \"message\": message[\"message\"],\n-            \"created_at\": message[\"created_at\"],\n-            \"html\": message[\"html\"],\n-            \"username\": user[\"username\"],\n-        }\n-\n-    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n-        results = []\n-        offset = page * page_size\n-        try:\n-            if timestamp:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                        \"timestamp\": timestamp,\n-                    },\n-                ):\n-                    results.append(model)\n-            elif page > 0:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                        \"timestamp\": timestamp,\n-                    },\n-                ):\n-                    results.append(model)\n-            else:\n-                async for model in self.query(\n-                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n-                    {\n-                        \"channel_uid\": channel_uid,\n-                        \"page_size\": page_size,\n-                        \"offset\": offset,\n-                    },\n-                ):\n-                    results.append(model)\n-\n-        except:\n-            pass\n-        results.sort(key=lambda x: x[\"created_at\"])\n-        return results\n+\tmapper_name='channel_message'\n+\tasync def create(B,channel_uid,user_uid,message):\n+\t\tE=user_uid;A=await B.new();A[_A]=channel_uid;A[_B]=E;A[_E]=message;D={};F=A.record;D.update(F);C=await B.app.services.user.get(uid=E);D.update({_B:C[_F],_C:C[_C],_I:C['nick'],_D:C[_D]})\n+\t\ttry:G=B.app.jinja2_env.get_template('message.html');A[_G]=G.render(**D)\n+\t\texcept Exception as H:print(H,flush=True)\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create channel message: {A.errors}.\")\n+\tasync def to_extended_dict(C,message):\n+\t\tA=message;B=await C.services.user.get(uid=A[_B])\n+\t\tif not B:return{}\n+\t\treturn{_F:A[_F],_D:B[_D],_B:A[_B],_A:A[_A],_I:B['nick'],_E:A[_E],_H:A[_H],_G:A[_G],_C:B[_C]}\n+\tasync def offset(D,channel_uid,page=0,timestamp=None,page_size=30):\n+\t\tJ='timestamp';E='offset';F='page_size';G=timestamp;H=channel_uid;C=page_size;A=[];I=page*C\n+\t\ttry:\n+\t\t\tif G:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I,J:G}):A.append(B)\n+\t\t\telif page>0:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size',{_A:H,F:C,E:I,J:G}):A.append(B)\n+\t\t\telse:\n+\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I}):A.append(B)\n+\t\texcept:pass\n+\t\tA.sort(key=lambda x:x[_H]);return A\n\\ No newline at end of file\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 388d5c0..14a9ad1 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,39 +1,7 @@\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class ChatService(BaseService):\n-\n-    async def send(self, user_uid, channel_uid, message):\n-        channel = await self.services.channel.get(uid=channel_uid)\n-        if not channel:\n-            raise Exception(\"Channel not found.\")\n-        channel_message = await self.services.channel_message.create(\n-            channel_uid, user_uid, message\n-        )\n-        channel_message_uid = channel_message[\"uid\"]\n-\n-        user = await self.services.user.get(uid=user_uid)\n-        channel[\"last_message_on\"] = now()\n-        await self.services.channel.save(channel)\n-\n-        await self.services.socket.broadcast(\n-            channel_uid,\n-            {\n-                \"message\": channel_message[\"message\"],\n-                \"html\": channel_message[\"html\"],\n-                \"user_uid\": user_uid,\n-                \"color\": user[\"color\"],\n-                \"channel_uid\": channel_uid,\n-                \"created_at\": channel_message[\"created_at\"],\n-                \"updated_at\": None,\n-                \"username\": user[\"username\"],\n-                \"uid\": channel_message[\"uid\"],\n-                \"user_nick\": user[\"nick\"],\n-            },\n-        )\n-        await self.app.create_task(\n-            self.services.notification.create_channel_message(channel_message_uid)\n-        )\n-\n-        return True\n+\tasync def send(A,user_uid,channel_uid,message):\n+\t\tH='username';I='created_at';J='color';K='html';L='message';D='uid';E=user_uid;C=channel_uid;F=await A.services.channel.get(uid=C)\n+\t\tif not F:raise Exception('Channel not found.')\n+\t\tB=await A.services.channel_message.create(C,E,message);M=B[D];G=await A.services.user.get(uid=E);F['last_message_on']=now();await A.services.channel.save(F);await A.services.socket.broadcast(C,{L:B[L],K:B[K],'user_uid':E,J:G[J],'channel_uid':C,I:B[I],'updated_at':None,H:G[H],D:B[D],'user_nick':G['nick']});await A.app.create_task(A.services.notification.create_channel_message(M));return True\n\\ No newline at end of file\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex 38035c7..e38b3fa 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -1,153 +1,41 @@\n+_H='Documents'\n+_G='Archives'\n+_F='Videos'\n+_E='Pictures'\n+_D='uid'\n+_C='user_uid'\n+_B='My Drive'\n+_A='name'\n from snek.system.service import BaseService\n-\n-\n class DriveService(BaseService):\n-\n-    mapper_name = \"drive\"\n-\n-    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n-    EXTENSIONS_VIDEOS = [\n-        \"mp4\",\n-        \"m4v\",\n-        \"mov\",\n-        \"wmv\",\n-        \"webm\",\n-        \"mkv\",\n-        \"mpg\",\n-        \"mpeg\",\n-        \"avi\",\n-        \"ogv\",\n-        \"ogg\",\n-        \"flv\",\n-        \"3gp\",\n-        \"3g2\",\n-    ]\n-    EXTENSIONS_ARCHIVES = [\n-        \"zip\",\n-        \"rar\",\n-        \"7z\",\n-        \"tar\",\n-        \"tar.gz\",\n-        \"tar.xz\",\n-        \"tar.bz2\",\n-        \"tar.lzma\",\n-        \"tar.lz\",\n-    ]\n-    EXTENSIONS_AUDIO = [\n-        \"mp3\",\n-        \"wav\",\n-        \"ogg\",\n-        \"flac\",\n-        \"m4a\",\n-        \"wma\",\n-        \"aac\",\n-        \"opus\",\n-        \"aiff\",\n-        \"au\",\n-        \"mid\",\n-        \"midi\",\n-    ]\n-    EXTENSIONS_DOCS = [\n-        \"pdf\",\n-        \"doc\",\n-        \"docx\",\n-        \"xls\",\n-        \"xlsx\",\n-        \"ppt\",\n-        \"pptx\",\n-        \"txt\",\n-        \"md\",\n-        \"json\",\n-        \"csv\",\n-        \"xml\",\n-        \"html\",\n-        \"css\",\n-        \"js\",\n-        \"py\",\n-        \"sql\",\n-        \"rs\",\n-        \"toml\",\n-        \"yml\",\n-        \"yaml\",\n-        \"ini\",\n-        \"conf\",\n-        \"config\",\n-        \"log\",\n-        \"csv\",\n-        \"tsv\",\n-        \"java\",\n-        \"cs\",\n-        \"csproj\",\n-        \"scss\",\n-        \"less\",\n-        \"sass\",\n-        \"json\",\n-        \"lock\",\n-        \"lock.json\",\n-        \"jsonl\",\n-    ]\n-\n-    async def get_drive_name_by_extension(self, extension):\n-        if extension.startswith(\".\"):\n-            extension = extension[1:]\n-        if extension in self.EXTENSIONS_PICTURES:\n-            return \"Pictures\"\n-        if extension in self.EXTENSIONS_VIDEOS:\n-            return \"Videos\"\n-        if extension in self.EXTENSIONS_ARCHIVES:\n-            return \"Archives\"\n-        if extension in self.EXTENSIONS_AUDIO:\n-            return \"Audio\"\n-        if extension in self.EXTENSIONS_DOCS:\n-            return \"Documents\"\n-        return \"My Drive\"\n-\n-    async def get_drive_by_extension(self, user_uid, extension):\n-        name = await self.get_drive_name_by_extension(extension)\n-        return await self.get_or_create(user_uid=user_uid, name=name)\n-\n-    async def get_by_user(self, user_uid, name=None):\n-        kwargs = {\"user_uid\": user_uid}\n-        async for model in self.find(**kwargs):\n-            if not name:\n-                yield model\n-            elif model[\"name\"] == name:\n-                yield model\n-            elif not model[\"name\"] and name == \"My Drive\":\n-                model[\"name\"] = \"My Drive\"\n-                await self.save(model)\n-                yield model\n-\n-    async def get_or_create(self, user_uid, name=None, extensions=None):\n-        kwargs = {\"user_uid\": user_uid}\n-        if name:\n-            kwargs[\"name\"] = name\n-        async for model in self.get_by_user(**kwargs):\n-            return model\n-\n-        model = await self.new()\n-        model[\"user_uid\"] = user_uid\n-        model[\"name\"] = name\n-        await self.save(model)\n-        return model\n-\n-    async def prepare_default_drives(self):\n-        async for drive_item in self.services.drive_item.find():\n-            extension = drive_item.extension\n-            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n-            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n-                drive_item[\"drive_uid\"] = drive[\"uid\"]\n-                await self.services.drive_item.save(drive_item)\n-\n-    async def prepare_default_drives_for_user(self, user_uid):\n-        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n-        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n-\n-    async def prepare_all(self):\n-        await self.prepare_default_drives()\n-        async for user in self.services.user.find():\n-            await self.prepare_default_drives_for_user(user[\"uid\"])\n+\tmapper_name='drive';EXTENSIONS_PICTURES=['jpg','jpeg','png','gif','svg','webp','tiff'];EXTENSIONS_VIDEOS=['mp4','m4v','mov','wmv','webm','mkv','mpg','mpeg','avi','ogv','ogg','flv','3gp','3g2'];EXTENSIONS_ARCHIVES=['zip','rar','7z','tar','tar.gz','tar.xz','tar.bz2','tar.lzma','tar.lz'];EXTENSIONS_AUDIO=['mp3','wav','ogg','flac','m4a','wma','aac','opus','aiff','au','mid','midi'];EXTENSIONS_DOCS=['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','json','csv','xml','html','css','js','py','sql','rs','toml','yml','yaml','ini','conf','config','log','csv','tsv','java','cs','csproj','scss','less','sass','json','lock','lock.json','jsonl']\n+\tasync def get_drive_name_by_extension(B,extension):\n+\t\tA=extension\n+\t\tif A.startswith('.'):A=A[1:]\n+\t\tif A in B.EXTENSIONS_PICTURES:return _E\n+\t\tif A in B.EXTENSIONS_VIDEOS:return _F\n+\t\tif A in B.EXTENSIONS_ARCHIVES:return _G\n+\t\tif A in B.EXTENSIONS_AUDIO:return'Audio'\n+\t\tif A in B.EXTENSIONS_DOCS:return _H\n+\t\treturn _B\n+\tasync def get_drive_by_extension(A,user_uid,extension):B=await A.get_drive_name_by_extension(extension);return await A.get_or_create(user_uid=user_uid,name=B)\n+\tasync def get_by_user(C,user_uid,name=None):\n+\t\tB=name;D={_C:user_uid}\n+\t\tasync for A in C.find(**D):\n+\t\t\tif not B:yield A\n+\t\t\telif A[_A]==B:yield A\n+\t\t\telif not A[_A]and B==_B:A[_A]=_B;await C.save(A);yield A\n+\tasync def get_or_create(B,user_uid,name=None,extensions=None):\n+\t\tD=user_uid;C=name;E={_C:D}\n+\t\tif C:E[_A]=C\n+\t\tasync for A in B.get_by_user(**E):return A\n+\t\tA=await B.new();A[_C]=D;A[_A]=C;await B.save(A);return A\n+\tasync def prepare_default_drives(B):\n+\t\tC='drive_uid'\n+\t\tasync for A in B.services.drive_item.find():\n+\t\t\tE=A.extension;D=await B.get_drive_by_extension(A[_C],E)\n+\t\t\tif not A[C]==D[_D]:A[C]=D[_D];await B.services.drive_item.save(A)\n+\tasync def prepare_default_drives_for_user(A,user_uid):B=user_uid;await A.get_or_create(user_uid=B,name=_B);await A.get_or_create(user_uid=B,name='Shared Drive');await A.get_or_create(user_uid=B,name=_E);await A.get_or_create(user_uid=B,name=_F);await A.get_or_create(user_uid=B,name=_G);await A.get_or_create(user_uid=B,name=_H)\n+\tasync def prepare_all(A):\n+\t\tawait A.prepare_default_drives()\n+\t\tasync for B in A.services.user.find():await A.prepare_default_drives_for_user(B[_D])\n\\ No newline at end of file\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex ce747c1..0740949 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,19 +1,7 @@\n from snek.system.service import BaseService\n-\n-\n class DriveItemService(BaseService):\n-\n-    mapper_name = \"drive_item\"\n-\n-    async def create(self, drive_uid, name, path, type_, size):\n-        model = await self.new()\n-        model[\"drive_uid\"] = drive_uid\n-        model[\"name\"] = name\n-        model[\"path\"] = str(path)\n-        model[\"extension\"] = str(name).split(\".\")[-1]\n-        model[\"file_type\"] = type_\n-        model[\"file_size\"] = size\n-        if await self.save(model):\n-            return model\n-        errors = await model.errors\n-        raise Exception(f\"Failed to create drive item: {errors}.\")\n+\tmapper_name='drive_item'\n+\tasync def create(B,drive_uid,name,path,type_,size):\n+\t\tA=await B.new();A['drive_uid']=drive_uid;A['name']=name;A['path']=str(path);A['extension']=str(name).split('.')[-1];A['file_type']=type_;A['file_size']=size\n+\t\tif await B.save(A):return A\n+\t\tC=await A.errors;raise Exception(f\"Failed to create drive item: {C}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex a22e8ae..968d426 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,65 +1,28 @@\n+_E='message'\n+_D='object_type'\n+_C='object_uid'\n+_B=False\n+_A='user_uid'\n from snek.system.model import now\n from snek.system.service import BaseService\n-\n-\n class NotificationService(BaseService):\n-    mapper_name = \"notification\"\n-\n-    async def mark_as_read(self, user_uid, channel_message_uid):\n-        model = await self.get(user_uid, object_uid=channel_message_uid)\n-        if not model:\n-            return False\n-        model[\"read_at\"] = now()\n-        await self.save(model)\n-        return True\n-\n-    async def get_unread_stats(self, user_uid):\n-        await self.query(\n-            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n-            {\"user_uid\": user_uid},\n-        )\n-\n-    async def create(self, object_uid, object_type, user_uid, message):\n-        model = await self.new()\n-        model[\"object_uid\"] = object_uid\n-        model[\"object_type\"] = object_type\n-        model[\"user_uid\"] = user_uid\n-        model[\"message\"] = message\n-        if await self.save(model):\n-            return model\n-        raise Exception(f\"Failed to create notification: {model.errors}.\")\n-\n-    async def create_channel_message(self, channel_message_uid):\n-        channel_message = await self.services.channel_message.get(\n-            uid=channel_message_uid\n-        )\n-        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n-        self.app.db.begin()\n-        async for channel_member in self.services.channel_member.find(\n-            channel_uid=channel_message[\"channel_uid\"],\n-            is_banned=False,\n-            is_muted=False,\n-            deleted_at=None,\n-        ):\n-            if not channel_member[\"new_count\"]:\n-                channel_member[\"new_count\"] = 0\n-            channel_member[\"new_count\"] += 1\n-\n-            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n-            if not usr:\n-                continue\n-            await self.services.channel_member.save(channel_member)\n-\n-            model = await self.new()\n-            model[\"object_uid\"] = channel_message_uid\n-            model[\"object_type\"] = \"channel_message\"\n-            model[\"user_uid\"] = channel_member[\"user_uid\"]\n-            model[\"message\"] = (\n-                f\"New message from {user['nick']} in {channel_member['label']}.\"\n-            )\n-            try:\n-                await self.save(model)\n-            except Exception:\n-                raise Exception(f\"Failed to create notification: {model.errors}.\")\n-\n-        self.app.db.commit()\n+\tmapper_name='notification'\n+\tasync def mark_as_read(B,user_uid,channel_message_uid):\n+\t\tA=await B.get(user_uid,object_uid=channel_message_uid)\n+\t\tif not A:return _B\n+\t\tA['read_at']=now();await B.save(A);return True\n+\tasync def get_unread_stats(A,user_uid):await A.query('SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type',{_A:user_uid})\n+\tasync def create(B,object_uid,object_type,user_uid,message):\n+\t\tA=await B.new();A[_C]=object_uid;A[_D]=object_type;A[_A]=user_uid;A[_E]=message\n+\t\tif await B.save(A):return A\n+\t\traise Exception(f\"Failed to create notification: {A.errors}.\")\n+\tasync def create_channel_message(A,channel_message_uid):\n+\t\tE=channel_message_uid;D='new_count';F=await A.services.channel_message.get(uid=E);G=await A.services.user.get(uid=F[_A]);A.app.db.begin()\n+\t\tasync for B in A.services.channel_member.find(channel_uid=F['channel_uid'],is_banned=_B,is_muted=_B,deleted_at=None):\n+\t\t\tif not B[D]:B[D]=0\n+\t\t\tB[D]+=1;H=await A.services.user.get(uid=B[_A])\n+\t\t\tif not H:continue\n+\t\t\tawait A.services.channel_member.save(B);C=await A.new();C[_C]=E;C[_D]='channel_message';C[_A]=B[_A];C[_E]=f\"New message from {G[\"nick\"]} in {B[\"label\"]}.\"\n+\t\t\ttry:await A.save(C)\n+\t\t\texcept Exception:raise Exception(f\"Failed to create notification: {C.errors}.\")\n+\t\tA.app.db.commit()\n\\ No newline at end of file\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex 120c232..be30602 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,52 +1,23 @@\n+_B='user_uid'\n+_A=False\n from snek.system.service import BaseService\n-import asyncio \n-import shutil\n-\n+import asyncio,shutil\n class RepositoryService(BaseService):\n-    mapper_name = \"repository\"\n-\n-    async def delete(self, user_uid, name):\n-        loop = asyncio.get_event_loop()\n-        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n-        try:\n-            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n-        except Exception as ex:\n-            print(ex)\n-\n-        await super().delete(user_uid=user_uid, name=name)\n-\n-\n-    async def exists(self, user_uid, name, **kwargs):\n-        kwargs[\"user_uid\"] = user_uid\n-        kwargs[\"name\"] = name\n-        return await super().exists(**kwargs)\n-\n-    async def init(self, user_uid, name):\n-        repository_path = await self.services.user.get_repository_path(user_uid)\n-        if not repository_path.exists():\n-            repository_path.mkdir(parents=True)\n-        repository_path = repository_path.joinpath(name)\n-        repository_path = str(repository_path)\n-        if not repository_path.endswith(\".git\"):\n-            repository_path += \".git\"\n-        command = ['git', 'init', '--bare', repository_path]\n-        process = await asyncio.subprocess.create_subprocess_exec(\n-            *command,\n-            stdout=asyncio.subprocess.PIPE,\n-            stderr=asyncio.subprocess.PIPE\n-        )\n-        stdout, stderr = await process.communicate()\n-        return process.returncode == 0\n-\n-    async def create(self, user_uid, name,is_private=False):\n-        if await self.exists(user_uid=user_uid, name=name):\n-            return False \n-\n-        if not await self.init(user_uid=user_uid, name=name):\n-            return False\n-\n-        model = await self.new()\n-        model[\"user_uid\"] = user_uid\n-        model[\"name\"] = name\n-        model[\"is_private\"] = is_private\n-        return await self.save(model)\n+\tmapper_name='repository'\n+\tasync def delete(B,user_uid,name):\n+\t\tA=user_uid;C=asyncio.get_event_loop();D=(await B.services.user.get_repository_path(A)).joinpath(name)\n+\t\ttry:await C.run_in_executor(None,shutil.rmtree,D)\n+\t\texcept Exception as E:print(E)\n+\t\tawait super().delete(user_uid=A,name=name)\n+\tasync def exists(B,user_uid,name,**A):A[_B]=user_uid;A['name']=name;return await super().exists(**A)\n+\tasync def init(D,user_uid,name):\n+\t\tB='.git';A=await D.services.user.get_repository_path(user_uid)\n+\t\tif not A.exists():A.mkdir(parents=True)\n+\t\tA=A.joinpath(name);A=str(A)\n+\t\tif not A.endswith(B):A+=B\n+\t\tE=['git','init','--bare',A];C=await asyncio.subprocess.create_subprocess_exec(*E,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);F,G=await C.communicate();return C.returncode==0\n+\tasync def create(A,user_uid,name,is_private=_A):\n+\t\tC=name;D=user_uid\n+\t\tif await A.exists(user_uid=D,name=C):return _A\n+\t\tif not await A.init(user_uid=D,name=C):return _A\n+\t\tB=await A.new();B[_B]=D;B['name']=C;B['is_private']=is_private;return await A.save(B)\n\\ No newline at end of file\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a3654d2..86c83a8 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,71 +1,36 @@\n+_B=False\n+_A=True\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n-\n-\n class SocketService(BaseService):\n-\n-    class Socket:\n-        def __init__(self, ws, user: UserModel):\n-            self.ws = ws\n-            self.is_connected = True\n-            self.user = user\n-\n-        async def send_json(self, data):\n-            if not self.is_connected:\n-                return False\n-            try:\n-                await self.ws.send_json(data)\n-            except Exception:\n-                self.is_connected = False\n-            return self.is_connected\n-\n-        async def close(self):\n-            if not self.is_connected:\n-                return True\n-\n-            await self.ws.close()\n-            self.is_connected = False\n-\n-            return True\n-\n-    def __init__(self, app):\n-        super().__init__(app)\n-        self.sockets = set()\n-        self.users = {}\n-        self.subscriptions = {}\n-\n-    async def add(self, ws, user_uid):\n-        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n-        self.sockets.add(s)\n-        if not self.users.get(user_uid):\n-            self.users[user_uid] = set()\n-        self.users[user_uid].add(s)\n-\n-    async def subscribe(self, ws, channel_uid, user_uid):\n-        if channel_uid not in self.subscriptions:\n-            self.subscriptions[channel_uid] = set()\n-        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n-        self.subscriptions[channel_uid].add(s)\n-\n-    async def send_to_user(self, user_uid, message):\n-        count = 0\n-        for s in self.users.get(user_uid, []):\n-            if await s.send_json(message):\n-                count += 1\n-        return count\n-\n-    async def broadcast(self, channel_uid, message):\n-        try:\n-            async for user_uid in self.services.channel_member.get_user_uids(\n-                channel_uid\n-            ):\n-                print(user_uid, flush=True)\n-                await self.send_to_user(user_uid, message)\n-        except Exception as ex:\n-            print(ex, flush=True)\n-        return True\n-\n-    async def delete(self, ws):\n-        for s in [sock for sock in self.sockets if sock.ws == ws]:\n-            await s.close()\n-            self.sockets.remove(s)\n+\tclass Socket:\n+\t\tdef __init__(A,ws,user):A.ws=ws;A.is_connected=_A;A.user=user\n+\t\tasync def send_json(A,data):\n+\t\t\tif not A.is_connected:return _B\n+\t\t\ttry:await A.ws.send_json(data)\n+\t\t\texcept Exception:A.is_connected=_B\n+\t\t\treturn A.is_connected\n+\t\tasync def close(A):\n+\t\t\tif not A.is_connected:return _A\n+\t\t\tawait A.ws.close();A.is_connected=_B;return _A\n+\tdef __init__(A,app):super().__init__(app);A.sockets=set();A.users={};A.subscriptions={}\n+\tasync def add(A,ws,user_uid):\n+\t\tB=user_uid;C=A.Socket(ws,await A.app.services.user.get(uid=B));A.sockets.add(C)\n+\t\tif not A.users.get(B):A.users[B]=set()\n+\t\tA.users[B].add(C)\n+\tasync def subscribe(A,ws,channel_uid,user_uid):\n+\t\tB=channel_uid\n+\t\tif B not in A.subscriptions:A.subscriptions[B]=set()\n+\t\tC=A.Socket(ws,await A.app.services.user.get(uid=user_uid));A.subscriptions[B].add(C)\n+\tasync def send_to_user(B,user_uid,message):\n+\t\tA=0\n+\t\tfor C in B.users.get(user_uid,[]):\n+\t\t\tif await C.send_json(message):A+=1\n+\t\treturn A\n+\tasync def broadcast(A,channel_uid,message):\n+\t\ttry:\n+\t\t\tasync for B in A.services.channel_member.get_user_uids(channel_uid):print(B,flush=_A);await A.send_to_user(B,message)\n+\t\texcept Exception as C:print(C,flush=_A)\n+\t\treturn _A\n+\tasync def delete(A,ws):\n+\t\tfor B in[A for A in A.sockets if A.ws==ws]:await B.close();A.sockets.remove(B)\n\\ No newline at end of file\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 76e6d1c..6ece3fd 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,91 +1,53 @@\n+_B='color'\n+_A=True\n import pathlib\n-\n from snek.system import security\n from snek.system.service import BaseService\n-\n-\n class UserService(BaseService):\n-    mapper_name = \"user\"\n-\n-    async def get_by_username(self, username):\n-        return await self.get(username=username)\n-\n-    async def search(self, query, **kwargs):\n-        query = query.strip().lower()\n-        if not query:\n-            return []\n-        results = []\n-        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n-            results.append(result)\n-        return results\n-\n-    async def validate_login(self, username, password):\n-        model = await self.get(username=username)\n-        if not model:\n-            return False\n-        if not await security.verify(password, model[\"password\"]):\n-            return False\n-        return True\n-\n-    async def save(self, user):\n-        if not user[\"color\"]:\n-            user[\"color\"] = await self.services.util.random_light_hex_color()\n-        return await super().save(user)\n-\n-    async def authenticate(self, username, password):\n-        print(username, password, flush=True)\n-        success = await self.validate_login(username, password)\n-        print(success, flush=True)\n-        if not success:\n-            return None\n-\n-        model = await self.get(username=username, deleted_at=None)\n-        return model\n-\n-    def get_admin_uids(self):\n-        return self.mapper.get_admin_uids()\n-\n-    async def get_repository_path(self, user_uid):\n-        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-\n-    async def get_static_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n-        if not path.exists():\n-            return None\n-        return path\n-\n-\n-\n-    async def get_template_path(self, user_uid):\n-        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n-        if not path.exists():\n-            return None\n-        return path\n-\n-    async def get_home_folder(self, user_uid):\n-        folder = pathlib.Path(f\"./drive/{user_uid}\")\n-        if not folder.exists():\n-            try:\n-                folder.mkdir(parents=True, exist_ok=True)\n-            except:\n-                pass\n-        return folder\n-\n-    async def register(self, email, username, password):\n-        if await self.exists(username=username):\n-            raise Exception(\"User already exists.\")\n-        model = await self.new()\n-        model[\"nick\"] = username\n-        model[\"color\"] = await self.services.util.random_light_hex_color()\n-        model.email.value = email\n-        model.username.value = username\n-        model.password.value = await security.hash(password)\n-        if await self.save(model):\n-            if model:\n-                channel = await self.services.channel.ensure_public_channel(\n-                    model[\"uid\"]\n-                )\n-                if not channel:\n-                    raise Exception(\"Failed to create public channel.\")\n-            return model\n-        raise Exception(f\"Failed to create user: {model.errors}.\")\n+\tmapper_name='user'\n+\tasync def get_by_username(A,username):return await A.get(username=username)\n+\tasync def search(C,query,**D):\n+\t\tA=query;A=A.strip().lower()\n+\t\tif not A:return[]\n+\t\tB=[]\n+\t\tasync for E in C.find(username={'ilike':'%'+A+'%'},**D):B.append(E)\n+\t\treturn B\n+\tasync def validate_login(C,username,password):\n+\t\tA=False;B=await C.get(username=username)\n+\t\tif not B:return A\n+\t\tif not await security.verify(password,B['password']):return A\n+\t\treturn _A\n+\tasync def save(B,user):\n+\t\tA=user\n+\t\tif not A[_B]:A[_B]=await B.services.util.random_light_hex_color()\n+\t\treturn await super().save(A)\n+\tasync def authenticate(B,username,password):\n+\t\tC=password;A=username;print(A,C,flush=_A);D=await B.validate_login(A,C);print(D,flush=_A)\n+\t\tif not D:return\n+\t\tE=await B.get(username=A,deleted_at=None);return E\n+\tdef get_admin_uids(A):return A.mapper.get_admin_uids()\n+\tasync def get_repository_path(A,user_uid):return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+\tasync def get_static_path(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+\t\tif not A.exists():return\n+\t\treturn A\n+\tasync def get_template_path(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+\t\tif not A.exists():return\n+\t\treturn A\n+\tasync def get_home_folder(B,user_uid):\n+\t\tA=pathlib.Path(f\"./drive/{user_uid}\")\n+\t\tif not A.exists():\n+\t\t\ttry:A.mkdir(parents=_A,exist_ok=_A)\n+\t\t\texcept:pass\n+\t\treturn A\n+\tasync def register(B,email,username,password):\n+\t\tC=username\n+\t\tif await B.exists(username=C):raise Exception('User already exists.')\n+\t\tA=await B.new();A['nick']=C;A[_B]=await B.services.util.random_light_hex_color();A.email.value=email;A.username.value=C;A.password.value=await security.hash(password)\n+\t\tif await B.save(A):\n+\t\t\tif A:\n+\t\t\t\tD=await B.services.channel.ensure_public_channel(A['uid'])\n+\t\t\t\tif not D:raise Exception('Failed to create public channel.')\n+\t\t\treturn A\n+\t\traise Exception(f\"Failed to create user: {A.errors}.\")\n\\ No newline at end of file\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex 4d11fa8..da9136a 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,35 +1,15 @@\n+_A='user_property'\n import json\n-\n from snek.system.service import BaseService\n-\n-\n class UserPropertyService(BaseService):\n-    mapper_name = \"user_property\"\n-\n-    async def set(self, user_uid, name, value):\n-        self.mapper.db[\"user_property\"].upsert(\n-            {\n-                \"user_uid\": user_uid,\n-                \"name\": name,\n-                \"value\": json.dumps(value, default=str),\n-            },\n-            [\"user_uid\", \"name\"],\n-        )\n-\n-    async def get(self, user_uid, name):\n-        try:\n-            return json.loads(\n-                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n-            )\n-        except Exception as ex:\n-            print(ex)\n-            return None\n-\n-    async def search(self, query, **kwargs):\n-        query = query.strip().lower()\n-        if not query:\n-            raise []\n-        results = []\n-        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n-            results.append(result)\n-        return results\n+\tmapper_name=_A\n+\tasync def set(C,user_uid,name,value):A='name';B='user_uid';C.mapper.db[_A].upsert({B:user_uid,A:name,'value':json.dumps(value,default=str)},[B,A])\n+\tasync def get(B,user_uid,name):\n+\t\ttry:return json.loads((await super().get(user_uid=user_uid,name=name))['value'])\n+\t\texcept Exception as A:print(A);return\n+\tasync def search(C,query,**D):\n+\t\tA=query;A=A.strip().lower()\n+\t\tif not A:raise[]\n+\t\tB=[]\n+\t\tasync for E in C.find(name={'ilike':'%'+A+'%'},**D):B.append(E)\n+\t\treturn B\n\\ No newline at end of file\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex b620d9c..73dbec3 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,14 +1,4 @@\n import random\n-\n from snek.system.service import BaseService\n-\n-\n class UtilService(BaseService):\n-\n-    async def random_light_hex_color(self):\n-\n-        r = random.randint(128, 255)\n-        g = random.randint(128, 255)\n-        b = random.randint(128, 255)\n-\n\\ No newline at end of file\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex f8bfeb7..0f3a69f 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,489 +1,207 @@\n-import os\n-import aiohttp\n+_O='branches'\n+_N='message'\n+_M='author'\n+_L='Invalid JSON data'\n+_K='origin'\n+_J='Repository not found'\n+_I='main'\n+_H='repository'\n+_G='branch'\n+_F='.git'\n+_E=None\n+_D='user'\n+_C='repo_name'\n+_B='username'\n+_A='repository_path'\n+import os,aiohttp\n from aiohttp import web\n-import git\n-import shutil\n-import json\n-import tempfile\n-import asyncio\n-import logging\n-import base64\n-import pathlib\n-logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n-logger = logging.getLogger('git_server')\n-\n+import git,shutil,json,tempfile,asyncio,logging,base64,pathlib\n+logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger=logging.getLogger('git_server')\n class GitApplication(web.Application):\n-    def __init__(self, parent=None):\n-        self.parent = parent\n-        super().__init__(client_max_size=1024*1024*1024*5)\n-        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n-        self.USERS = {\n-            'x': 'x',\n-            'bob': 'bobpass',\n-        }\n-        self.add_routes([\n-            web.post('/create/{repo_name}', self.create_repository),\n-            web.delete('/delete/{repo_name}', self.delete_repository),\n-            web.get('/clone/{repo_name}', self.clone_repository),\n-            web.post('/push/{repo_name}', self.push_repository),\n-            web.post('/pull/{repo_name}', self.pull_repository),\n-            web.get('/status/{repo_name}', self.status_repository),\n-            web.get('/list', self.list_repositories),\n-            web.get('/branches/{repo_name}', self.list_branches),\n-            web.post('/branches/{repo_name}', self.create_branch),\n-            web.get('/log/{repo_name}', self.commit_log),\n-            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n-            web.get('/{path:.+}/info/refs', self.git_smart_http),\n-            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n-            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n-            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n-            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n-            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n-        ])\n-\n-\n-    async def check_basic_auth(self, request):\n-        auth_header = request.headers.get(\"Authorization\", \"\")\n-        if not auth_header.startswith(\"Basic \"):\n-            return None,None\n-        encoded_creds = auth_header.split(\"Basic \")[1]\n-        decoded_creds = base64.b64decode(encoded_creds).decode()\n-        username, password = decoded_creds.split(\":\", 1)\n-        request[\"user\"] = await self.parent.services.user.authenticate(\n-            username=username, password=password\n-        )\n-        if not request[\"user\"]:\n-            return None,None\n-        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n-            request[\"user\"][\"uid\"]\n-        )\n-\n-        return request[\"user\"]['username'],request[\"repository_path\"]\n-\n-\n-    @staticmethod\n-    def require_auth(handler):\n-        async def wrapped(self, request, *args, **kwargs):\n-            username, repository_path = await self.check_basic_auth(request)\n-            if not username or not repository_path:\n-                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n-            request['username'] = username\n-            request['repository_path'] = repository_path\n-            return await handler(self, request, *args, **kwargs)\n-        return wrapped\n-\n-    def repo_path(self, repository_path, repo_name):\n-        return repository_path.joinpath(repo_name + '.git')\n-\n-    def check_repo_exists(self, repository_path, repo_name):\n-        repo_dir = self.repo_path(repository_path, repo_name)\n-        if not os.path.exists(repo_dir):\n-            return web.Response(text=\"Repository not found\", status=404)\n-        return None\n-\n-    @require_auth\n-    async def create_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        if not repo_name or '/' in repo_name or '..' in repo_name:\n-            return web.Response(text=\"Invalid repository name\", status=400)\n-        repo_dir = self.repo_path(repository_path, repo_name)\n-        if os.path.exists(repo_dir):\n-            return web.Response(text=\"Repository already exists\", status=400)\n-        try:\n-            git.Repo.init(repo_dir, bare=True)\n-            logger.info(f\"Created repository: {repo_name} for user {username}\")\n-            return web.Response(text=f\"Created repository {repo_name}\")\n-        except Exception as e:\n-            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n-            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def delete_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            shutil.rmtree(self.repo_path(repository_path, repo_name))\n-            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n-            return web.Response(text=f\"Deleted repository {repo_name}\")\n-        except Exception as e:\n-            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n-            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def clone_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        host = request.host\n-        response_data = {\n-            \"repository\": repo_name,\n-            \"clone_command\": f\"git clone {clone_url}\",\n-            \"clone_url\": clone_url\n-        }\n-        return web.json_response(response_data)\n-\n-    @require_auth\n-    async def push_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            return web.Response(text=\"Invalid JSON data\", status=400)\n-        commit_message = data.get('commit_message', 'Update from server')\n-        branch = data.get('branch', 'main')\n-        changes = data.get('changes', [])\n-        if not changes:\n-            return web.Response(text=\"No changes provided\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-            for change in changes:\n-                file_path = os.path.join(temp_dir, change.get('file', ''))\n-                content = change.get('content', '')\n-                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n-                with open(file_path, 'w') as f:\n-                    f.write(content)\n-            temp_repo.git.add(A=True)\n-            if not temp_repo.config_reader().has_section('user'):\n-                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n-                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n-            temp_repo.index.commit(commit_message)\n-            origin = temp_repo.remote('origin')\n-            origin.push(refspec=f\"{branch}:{branch}\")\n-        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n-        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n-\n-    @require_auth\n-    async def pull_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            data = {}\n-        remote_url = data.get('remote_url')\n-        branch = data.get('branch', 'main')\n-        if not remote_url:\n-            return web.Response(text=\"Remote URL is required\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                remote_name = \"pull_source\"\n-                try:\n-                    remote = local_repo.create_remote(remote_name, remote_url)\n-                except git.GitCommandError:\n-                    remote = local_repo.remote(remote_name)\n-                    remote.set_url(remote_url)\n-                remote.fetch()\n-                local_repo.git.merge(f\"{remote_name}/{branch}\")\n-                origin = local_repo.remote('origin')\n-                origin.push()\n-                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n-                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n-            except Exception as e:\n-                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def status_repository(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                branches = [b.name for b in temp_repo.branches]\n-                active_branch = temp_repo.active_branch.name\n-                commits = []\n-                for commit in list(temp_repo.iter_commits(max_count=5)):\n-                    commits.append({\n-                        \"id\": commit.hexsha,\n-                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n-                        \"date\": commit.committed_datetime.isoformat(),\n-                        \"message\": commit.message\n-                    })\n-                files = []\n-                for root, dirs, filenames in os.walk(temp_dir):\n-                    if '.git' in root:\n-                        continue\n-                    for filename in filenames:\n-                        full_path = os.path.join(root, filename)\n-                        rel_path = os.path.relpath(full_path, temp_dir)\n-                        files.append(rel_path)\n-                status_info = {\n-                    \"repository\": repo_name,\n-                    \"branches\": branches,\n-                    \"active_branch\": active_branch,\n-                    \"recent_commits\": commits,\n-                    \"files\": files\n-                }\n-                return web.json_response(status_info)\n-            except Exception as e:\n-                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def list_repositories(self, request):\n-        username = request['username']\n-        try:\n-            repos = []\n-            user_dir = self.REPO_DIR\n-            if os.path.exists(user_dir):\n-                for item in os.listdir(user_dir):\n-                    item_path = os.path.join(user_dir, item)\n-                    if os.path.isdir(item_path) and item.endswith('.git'):\n-                        repos.append(item[:-4])\n-            if request.query.get('format') == 'json':\n-                return web.json_response({\"repositories\": repos})\n-            else:\n-                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n-        except Exception as e:\n-            logger.error(f\"Error listing repositories: {str(e)}\")\n-            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def list_branches(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-            branches = [b.name for b in temp_repo.branches]\n-            return web.json_response({\"branches\": branches})\n-\n-    @require_auth\n-    async def create_branch(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            data = await request.json()\n-        except json.JSONDecodeError:\n-            return web.Response(text=\"Invalid JSON data\", status=400)\n-        branch_name = data.get('branch_name')\n-        start_point = data.get('start_point', 'HEAD')\n-        if not branch_name:\n-            return web.Response(text=\"Branch name is required\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                temp_repo.git.branch(branch_name, start_point)\n-                temp_repo.git.push('origin', branch_name)\n-                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n-                return web.Response(text=f\"Created branch {branch_name}\")\n-            except Exception as e:\n-                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def commit_log(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        try:\n-            limit = int(request.query.get('limit', 10))\n-            branch = request.query.get('branch', 'main')\n-        except ValueError:\n-            return web.Response(text=\"Invalid limit parameter\", status=400)\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                commits = []\n-                try:\n-                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n-                        commits.append({\n-                            \"id\": commit.hexsha,\n-                            \"short_id\": commit.hexsha[:7],\n-                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n-                            \"date\": commit.committed_datetime.isoformat(),\n-                            \"message\": commit.message.strip()\n-                        })\n-                except git.GitCommandError as e:\n-                    if \"unknown revision or path\" in str(e):\n-                        commits = []\n-                    else:\n-                        raise\n-                return web.json_response({\n-                    \"repository\": repo_name,\n-                    \"branch\": branch,\n-                    \"commits\": commits\n-                })\n-            except Exception as e:\n-                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def file_content(self, request):\n-        username = request['username']\n-        repo_name = request.match_info['repo_name']\n-        file_path = request.match_info.get('file_path', '')\n-        branch = request.query.get('branch', 'main')\n-        repository_path = request['repository_path']\n-        error_response = self.check_repo_exists(repository_path, repo_name)\n-        if error_response:\n-            return error_response\n-        with tempfile.TemporaryDirectory() as temp_dir:\n-            try:\n-                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n-                try:\n-                    temp_repo.git.checkout(branch)\n-                except git.GitCommandError:\n-                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n-                file_full_path = os.path.join(temp_dir, file_path)\n-                if not os.path.exists(file_full_path):\n-                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n-                if os.path.isdir(file_full_path):\n-                    files = os.listdir(file_full_path)\n-                    return web.json_response({\n-                        \"repository\": repo_name,\n-                        \"path\": file_path,\n-                        \"type\": \"directory\",\n-                        \"contents\": files\n-                    })\n-                else:\n-                    try:\n-                        with open(file_full_path, 'r') as f:\n-                            content = f.read()\n-                        return web.Response(text=content)\n-                    except UnicodeDecodeError:\n-                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n-            except Exception as e:\n-                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n-                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n-\n-    @require_auth\n-    async def git_smart_http(self, request):\n-        username = request['username']\n-        repository_path = request['repository_path']\n-        path = request.path\n-        async def get_repository_path():\n-            req_path = path.lstrip('/')\n-            if req_path.endswith('/info/refs'):\n-                repo_name = req_path[:-len('/info/refs')]\n-            elif req_path.endswith('/git-upload-pack'):\n-                repo_name = req_path[:-len('/git-upload-pack')]\n-            elif req_path.endswith('/git-receive-pack'):\n-                repo_name = req_path[:-len('/git-receive-pack')]\n-            else:\n-                repo_name = req_path\n-            if repo_name.endswith('.git'):\n-                repo_name = repo_name[:-4]\n-            repo_name = repo_name[4:]\n-            repo_dir = repository_path.joinpath(repo_name + \".git\")\n-            logger.info(f\"Resolved repo path: {repo_dir}\")\n-            return repo_dir \n-        async def handle_info_refs(service):\n-            repo_path = await get_repository_path()\n-        \n-            logger.info(f\"handle_info_refs: {repo_path}\")\n-            if not os.path.exists(repo_path):\n-                return web.Response(text=\"Repository not found\", status=404)\n-            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n-            try:\n-                process = await asyncio.create_subprocess_exec(\n-                    *cmd,\n-                    stdout=asyncio.subprocess.PIPE,\n-                    stderr=asyncio.subprocess.PIPE\n-                )\n-                stdout, stderr = await process.communicate()\n-                if process.returncode != 0:\n-                    logger.error(f\"Git command failed: {stderr.decode()}\")\n-                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n-                response = web.StreamResponse(\n-                    status=200,\n-                    reason='OK',\n-                    headers={\n-                        'Content-Type': f'application/x-{service}-advertisement',\n-                        'Cache-Control': 'no-cache'\n-                    }\n-                )\n-                await response.prepare(request)\n-                length = len(packet) + 4\n-                header = f\"{length:04x}\"\n-                await response.write(f\"{header}{packet}0000\".encode())\n-                await response.write(stdout)\n-                return response\n-            except Exception as e:\n-                logger.error(f\"Error handling info/refs: {str(e)}\")\n-                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n-        async def handle_service_rpc(service):\n-            repo_path = await get_repository_path()\n-            logger.info(f\"handle_service_rpc: {repo_path}\")\n-            if not os.path.exists(repo_path):\n-                return web.Response(text=\"Repository not found\", status=404)\n-            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n-                return web.Response(text=\"Invalid Content-Type\", status=403)\n-            body = await request.read()\n-            cmd = [service, '--stateless-rpc', str(repo_path)]\n-            try:\n-                process = await asyncio.create_subprocess_exec(\n-                    *cmd,\n-                    stdin=asyncio.subprocess.PIPE,\n-                    stdout=asyncio.subprocess.PIPE,\n-                    stderr=asyncio.subprocess.PIPE\n-                )\n-                stdout, stderr = await process.communicate(input=body)\n-                if process.returncode != 0:\n-                    logger.error(f\"Git command failed: {stderr.decode()}\")\n-                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n-                return web.Response(\n-                    body=stdout,\n-                    content_type=f'application/x-{service}-result'\n-                )\n-            except Exception as e:\n-                logger.error(f\"Error handling service RPC: {str(e)}\")\n-                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n-        if request.method == 'GET' and path.endswith('/info/refs'):\n-            service = request.query.get('service')\n-            if service in ('git-upload-pack', 'git-receive-pack'):\n-                return await handle_info_refs(service)\n-            else:\n-                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n-        elif request.method == 'POST' and '/git-upload-pack' in path:\n-            return await handle_service_rpc('git-upload-pack')\n-        elif request.method == 'POST' and '/git-receive-pack' in path:\n-            return await handle_service_rpc('git-receive-pack')\n-        return web.Response(text=\"Not found\", status=404)\n-\n-if __name__ == '__main__':\n-    try:\n-        import uvloop\n-        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-        logger.info(\"Using uvloop for improved performance\")\n-    except ImportError:\n-        logger.info(\"uvloop not available, using standard event loop\")\n-    app = GitApplication()\n-    logger.info(\"Starting Git server on port 8080\")\n-    web.run_app(app, port=8080)\n+\tdef __init__(A,parent=_E):B='/branches/{repo_name}';A.parent=parent;super().__init__(client_max_size=5368709120);A.REPO_DIR='drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545';A.USERS={'x':'x','bob':'bobpass'};A.add_routes([web.post('/create/{repo_name}',A.create_repository),web.delete('/delete/{repo_name}',A.delete_repository),web.get('/clone/{repo_name}',A.clone_repository),web.post('/push/{repo_name}',A.push_repository),web.post('/pull/{repo_name}',A.pull_repository),web.get('/status/{repo_name}',A.status_repository),web.get('/list',A.list_repositories),web.get(B,A.list_branches),web.post(B,A.create_branch),web.get('/log/{repo_name}',A.commit_log),web.get('/file/{repo_name}/{file_path:.*}',A.file_content),web.get('/{path:.+}/info/refs',A.git_smart_http),web.post('/{path:.+}/git-upload-pack',A.git_smart_http),web.post('/{path:.+}/git-receive-pack',A.git_smart_http),web.get('/{repo_name}.git/info/refs',A.git_smart_http),web.post('/{repo_name}.git/git-upload-pack',A.git_smart_http),web.post('/{repo_name}.git/git-receive-pack',A.git_smart_http)])\n+\tasync def check_basic_auth(B,request):\n+\t\tC='Basic ';A=request;D=A.headers.get('Authorization','')\n+\t\tif not D.startswith(C):return _E,_E\n+\t\tE=D.split(C)[1];F=base64.b64decode(E).decode();G,H=F.split(':',1);A[_D]=await B.parent.services.user.authenticate(username=G,password=H)\n+\t\tif not A[_D]:return _E,_E\n+\t\tA[_A]=await B.parent.services.user.get_repository_path(A[_D]['uid']);return A[_D][_B],A[_A]\n+\t@staticmethod\n+\tdef require_auth(handler):\n+\t\tasync def A(self,request,*D,**E):\n+\t\t\tA=request;B,C=await self.check_basic_auth(A)\n+\t\t\tif not B or not C:return web.Response(status=401,headers={'WWW-Authenticate':'Basic'},text='Authentication required')\n+\t\t\tA[_B]=B;A[_A]=C;return await handler(self,A,*D,**E)\n+\t\treturn A\n+\tdef repo_path(A,repository_path,repo_name):return repository_path.joinpath(repo_name+_F)\n+\tdef check_repo_exists(A,repository_path,repo_name):\n+\t\tB=A.repo_path(repository_path,repo_name)\n+\t\tif not os.path.exists(B):return web.Response(text=_J,status=404)\n+\t@require_auth\n+\tasync def create_repository(self,request):\n+\t\tB=request;E=B[_B];A=B.match_info[_C];F=B[_A]\n+\t\tif not A or'/'in A or'..'in A:return web.Response(text='Invalid repository name',status=400)\n+\t\tC=self.repo_path(F,A)\n+\t\tif os.path.exists(C):return web.Response(text='Repository already exists',status=400)\n+\t\ttry:git.Repo.init(C,bare=True);logger.info(f\"Created repository: {A} for user {E}\");return web.Response(text=f\"Created repository {A}\")\n+\t\texcept Exception as D:logger.error(f\"Error creating repository {A}: {str(D)}\");return web.Response(text=f\"Error creating repository: {str(D)}\",status=500)\n+\t@require_auth\n+\tasync def delete_repository(self,request):\n+\t\tB=request;F=B[_B];A=B.match_info[_C];C=B[_A];D=self.check_repo_exists(C,A)\n+\t\tif D:return D\n+\t\ttry:shutil.rmtree(self.repo_path(C,A));logger.info(f\"Deleted repository: {A} for user {F}\");return web.Response(text=f\"Deleted repository {A}\")\n+\t\texcept Exception as E:logger.error(f\"Error deleting repository {A}: {str(E)}\");return web.Response(text=f\"Error deleting repository: {str(E)}\",status=500)\n+\t@require_auth\n+\tasync def clone_repository(self,request):\n+\t\tA=request;H=A[_B];B=A.match_info[_C];E=A[_A];C=self.check_repo_exists(E,B)\n+\t\tif C:return C\n+\t@require_auth\n+\tasync def push_repository(self,request):\n+\t\tB=request;L=B[_B];C=B.match_info[_C];E=B[_A];F=self.check_repo_exists(E,C)\n+\t\tif F:return F\n+\t\ttry:D=await B.json()\n+\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n+\t\tM=D.get('commit_message','Update from server');G=D.get(_G,_I);H=D.get('changes',[])\n+\t\tif not H:return web.Response(text='No changes provided',status=400)\n+\t\twith tempfile.TemporaryDirectory()as I:\n+\t\t\tA=git.Repo.clone_from(self.repo_path(E,C),I)\n+\t\t\tfor J in H:\n+\t\t\t\tK=os.path.join(I,J.get('file',''));N=J.get('content','');os.makedirs(os.path.dirname(K),exist_ok=True)\n+\t\t\t\twith open(K,'w')as O:O.write(N)\n+\t\t\tA.git.add(A=True)\n+\t\t\tif not A.config_reader().has_section(_D):A.config_writer().set_value(_D,'name','Git Server').release();A.config_writer().set_value(_D,'email','git@server.local').release()\n+\t\t\tA.index.commit(M);P=A.remote(_K);P.push(refspec=f\"{G}:{G}\")\n+\t\tlogger.info(f\"Pushed to repository: {C} for user {L}\");return web.Response(text=f\"Successfully pushed changes to {C}\")\n+\t@require_auth\n+\tasync def pull_repository(self,request):\n+\t\tC=request;K=C[_B];A=C.match_info[_C];H=C[_A];I=self.check_repo_exists(H,A)\n+\t\tif I:return I\n+\t\ttry:E=await C.json()\n+\t\texcept json.JSONDecodeError:E={}\n+\t\tB=E.get('remote_url');L=E.get(_G,_I)\n+\t\tif not B:return web.Response(text='Remote URL is required',status=400)\n+\t\twith tempfile.TemporaryDirectory()as M:\n+\t\t\ttry:\n+\t\t\t\tD=git.Repo.clone_from(self.repo_path(H,A),M);F='pull_source'\n+\t\t\t\ttry:G=D.create_remote(F,B)\n+\t\t\t\texcept git.GitCommandError:G=D.remote(F);G.set_url(B)\n+\t\t\t\tG.fetch();D.git.merge(f\"{F}/{L}\");N=D.remote(_K);N.push();logger.info(f\"Pulled to repository {A} from {B} for user {K}\");return web.Response(text=f\"Successfully pulled changes from {B} to {A}\")\n+\t\t\texcept Exception as J:logger.error(f\"Error pulling to {A}: {str(J)}\");return web.Response(text=f\"Error pulling changes: {str(J)}\",status=500)\n+\t@require_auth\n+\tasync def status_repository(self,request):\n+\t\tC=request;S=C[_B];B=C.match_info[_C];F=C[_A];G=self.check_repo_exists(F,B)\n+\t\tif G:return G\n+\t\twith tempfile.TemporaryDirectory()as D:\n+\t\t\ttry:\n+\t\t\t\tE=git.Repo.clone_from(self.repo_path(F,B),D);L=[A.name for A in E.branches];M=E.active_branch.name;H=[]\n+\t\t\t\tfor A in list(E.iter_commits(max_count=5)):H.append({'id':A.hexsha,_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message})\n+\t\t\t\tI=[]\n+\t\t\t\tfor(J,T,N)in os.walk(D):\n+\t\t\t\t\tif _F in J:continue\n+\t\t\t\t\tfor O in N:P=os.path.join(J,O);Q=os.path.relpath(P,D);I.append(Q)\n+\t\t\t\tR={_H:B,_O:L,'active_branch':M,'recent_commits':H,'files':I};return web.json_response(R)\n+\t\t\texcept Exception as K:logger.error(f\"Error getting status for {B}: {str(K)}\");return web.Response(text=f\"Error getting repository status: {str(K)}\",status=500)\n+\t@require_auth\n+\tasync def list_repositories(self,request):\n+\t\tD=request;G=D[_B]\n+\t\ttry:\n+\t\t\tA=[];B=self.REPO_DIR\n+\t\t\tif os.path.exists(B):\n+\t\t\t\tfor C in os.listdir(B):\n+\t\t\t\t\tF=os.path.join(B,C)\n+\t\t\t\t\tif os.path.isdir(F)and C.endswith(_F):A.append(C[:-4])\n+\t\t\tif D.query.get('format')=='json':return web.json_response({'repositories':A})\n+\t\t\telse:return web.Response(text='\\n'.join(A)if A else'No repositories found')\n+\t\texcept Exception as E:logger.error(f\"Error listing repositories: {str(E)}\");return web.Response(text=f\"Error listing repositories: {str(E)}\",status=500)\n+\t@require_auth\n+\tasync def list_branches(self,request):\n+\t\tA=request;H=A[_B];B=A.match_info[_C];C=A[_A];D=self.check_repo_exists(C,B)\n+\t\tif D:return D\n+\t\twith tempfile.TemporaryDirectory()as E:F=git.Repo.clone_from(self.repo_path(C,B),E);G=[A.name for A in F.branches];return web.json_response({_O:G})\n+\t@require_auth\n+\tasync def create_branch(self,request):\n+\t\tB=request;I=B[_B];C=B.match_info[_C];D=B[_A];E=self.check_repo_exists(D,C)\n+\t\tif E:return E\n+\t\ttry:F=await B.json()\n+\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n+\t\tA=F.get('branch_name');J=F.get('start_point','HEAD')\n+\t\tif not A:return web.Response(text='Branch name is required',status=400)\n+\t\twith tempfile.TemporaryDirectory()as K:\n+\t\t\ttry:G=git.Repo.clone_from(self.repo_path(D,C),K);G.git.branch(A,J);G.git.push(_K,A);logger.info(f\"Created branch {A} in repository {C} for user {I}\");return web.Response(text=f\"Created branch {A}\")\n+\t\t\texcept Exception as H:logger.error(f\"Error creating branch {A} in {C}: {str(H)}\");return web.Response(text=f\"Error creating branch: {str(H)}\",status=500)\n+\t@require_auth\n+\tasync def commit_log(self,request):\n+\t\tB=request;L=B[_B];C=B.match_info[_C];F=B[_A];G=self.check_repo_exists(F,C)\n+\t\tif G:return G\n+\t\ttry:I=int(B.query.get('limit',10));H=B.query.get(_G,_I)\n+\t\texcept ValueError:return web.Response(text='Invalid limit parameter',status=400)\n+\t\twith tempfile.TemporaryDirectory()as J:\n+\t\t\ttry:\n+\t\t\t\tK=git.Repo.clone_from(self.repo_path(F,C),J);E=[]\n+\t\t\t\ttry:\n+\t\t\t\t\tfor A in list(K.iter_commits(H,max_count=I)):E.append({'id':A.hexsha,'short_id':A.hexsha[:7],_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message.strip()})\n+\t\t\t\texcept git.GitCommandError as D:\n+\t\t\t\t\tif'unknown revision or path'in str(D):E=[]\n+\t\t\t\t\telse:raise\n+\t\t\t\treturn web.json_response({_H:C,_G:H,'commits':E})\n+\t\t\texcept Exception as D:logger.error(f\"Error getting commit log for {C}: {str(D)}\");return web.Response(text=f\"Error getting commit log: {str(D)}\",status=500)\n+\t@require_auth\n+\tasync def file_content(self,request):\n+\t\tA=request;N=A[_B];B=A.match_info[_C];C=A.match_info.get('file_path','');E=A.query.get(_G,_I);F=A[_A];G=self.check_repo_exists(F,B)\n+\t\tif G:return G\n+\t\twith tempfile.TemporaryDirectory()as H:\n+\t\t\ttry:\n+\t\t\t\tJ=git.Repo.clone_from(self.repo_path(F,B),H)\n+\t\t\t\ttry:J.git.checkout(E)\n+\t\t\t\texcept git.GitCommandError:return web.Response(text=f\"Branch '{E}' not found\",status=404)\n+\t\t\t\tD=os.path.join(H,C)\n+\t\t\t\tif not os.path.exists(D):return web.Response(text=f\"File '{C}' not found\",status=404)\n+\t\t\t\tif os.path.isdir(D):K=os.listdir(D);return web.json_response({_H:B,'path':C,'type':'directory','contents':K})\n+\t\t\t\telse:\n+\t\t\t\t\ttry:\n+\t\t\t\t\t\twith open(D,'r')as L:M=L.read()\n+\t\t\t\t\t\treturn web.Response(text=M)\n+\t\t\t\t\texcept UnicodeDecodeError:return web.Response(text=f\"Cannot display binary file content for '{C}'\",status=400)\n+\t\t\texcept Exception as I:logger.error(f\"Error getting file content from {B}: {str(I)}\");return web.Response(text=f\"Error getting file content: {str(I)}\",status=500)\n+\t@require_auth\n+\tasync def git_smart_http(self,request):\n+\t\tB='POST';G='git-receive-pack';H='git-upload-pack';I='Content-Type';J='--stateless-rpc';D='/git-receive-pack';E='/git-upload-pack';F='/info/refs';A=request;P=A[_B];N=A[_A];C=A.path\n+\t\tasync def K():\n+\t\t\tB=C.lstrip('/')\n+\t\t\tif B.endswith(F):A=B[:-len(F)]\n+\t\t\telif B.endswith(E):A=B[:-len(E)]\n+\t\t\telif B.endswith(D):A=B[:-len(D)]\n+\t\t\telse:A=B\n+\t\t\tif A.endswith(_F):A=A[:-4]\n+\t\t\tA=A[4:];G=N.joinpath(A+_F);logger.info(f\"Resolved repo path: {G}\");return G\n+\t\tasync def O(service):\n+\t\t\tC=service;D=await K();logger.info(f\"handle_info_refs: {D}\")\n+\t\t\tif not os.path.exists(D):return web.Response(text=_J,status=404)\n+\t\t\tL=[C,J,'--advertise-refs',str(D)]\n+\t\t\ttry:\n+\t\t\t\tE=await asyncio.create_subprocess_exec(*L,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);M,F=await E.communicate()\n+\t\t\t\tif E.returncode!=0:logger.error(f\"Git command failed: {F.decode()}\");return web.Response(text=f\"Git error: {F.decode()}\",status=500)\n+\t\t\texcept Exception as H:logger.error(f\"Error handling info/refs: {str(H)}\");return web.Response(text=f\"Server error: {str(H)}\",status=500)\n+\t\tasync def L(service):\n+\t\t\tB=service;C=await K();logger.info(f\"handle_service_rpc: {C}\")\n+\t\t\tif not os.path.exists(C):return web.Response(text=_J,status=404)\n+\t\t\tif not A.headers.get(I)==f\"application/x-{B}-request\":return web.Response(text='Invalid Content-Type',status=403)\n+\t\t\tG=await A.read();H=[B,J,str(C)]\n+\t\t\ttry:\n+\t\t\t\tD=await asyncio.create_subprocess_exec(*H,stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);L,E=await D.communicate(input=G)\n+\t\t\t\tif D.returncode!=0:logger.error(f\"Git command failed: {E.decode()}\");return web.Response(text=f\"Git error: {E.decode()}\",status=500)\n+\t\t\t\treturn web.Response(body=L,content_type=f\"application/x-{B}-result\")\n+\t\t\texcept Exception as F:logger.error(f\"Error handling service RPC: {str(F)}\");return web.Response(text=f\"Server error: {str(F)}\",status=500)\n+\t\tif A.method=='GET'and C.endswith(F):\n+\t\t\tM=A.query.get('service')\n+\t\t\tif M in(H,G):return await O(M)\n+\t\t\telse:return web.Response(text='Smart HTTP requires service parameter',status=400)\n+\t\telif A.method==B and E in C:return await L(H)\n+\t\telif A.method==B and D in C:return await L(G)\n+\t\treturn web.Response(text='Not found',status=404)\n+if __name__=='__main__':\n+\ttry:import uvloop;asyncio.set_event_loop_policy(uvloop.EventLoopPolicy());logger.info('Using uvloop for improved performance')\n+\texcept ImportError:logger.info('uvloop not available, using standard event loop')\n+\tapp=GitApplication();logger.info('Starting Git server on port 8080');web.run_app(app,port=8080)\n\\ No newline at end of file\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex eed888a..57e90a3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,144 +1,67 @@\n-import functools\n-import json\n-\n+_C='delete'\n+_B='set'\n+_A='get'\n+import functools,json\n from snek.system import security\n-\n-cache = functools.cache\n-\n-CACHE_MAX_ITEMS_DEFAULT = 5000\n-\n-\n+cache=functools.cache\n+CACHE_MAX_ITEMS_DEFAULT=5000\n class Cache:\n-    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n-        self.app = app\n-        self.cache = {}\n-        self.max_items = max_items\n-        self.stats = {}\n-        self.lru = []\n-        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n-\n-    async def get(self, args):\n-        await self.update_stat(args, \"get\")\n-        try:\n-            self.lru.pop(self.lru.index(args))\n-        except:\n-            return None\n-        self.lru.insert(0, args)\n-        while len(self.lru) > self.max_items:\n-            self.cache.pop(self.lru[-1])\n-            self.lru.pop()\n-        return self.cache[args]\n-\n-    async def get_stats(self):\n-        all_ = []\n-        for key in self.lru:\n-            all_.append(\n-                {\n-                    \"key\": key,\n-                    \"set\": self.stats[key][\"set\"],\n-                    \"get\": self.stats[key][\"get\"],\n-                    \"delete\": self.stats[key][\"delete\"],\n-                    \"value\": str(self.serialize(self.cache[key].record)),\n-                }\n-            )\n-        return all_\n-\n-    def serialize(self, obj):\n-        cpy = obj.copy()\n-        cpy.pop(\"created_at\", None)\n-        cpy.pop(\"deleted_at\", None)\n-        cpy.pop(\"email\", None)\n-        cpy.pop(\"password\", None)\n-        return cpy\n-\n-    async def update_stat(self, key, action):\n-        if key not in self.stats:\n-            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n-        self.stats[key][action] = self.stats[key][action] + 1\n-\n-    def json_default(self, value):\n-        try:\n-            return json.dumps(value.__dict__, default=str)\n-        except:\n-            return str(value)\n-\n-    async def create_cache_key(self, args, kwargs):\n-        return await security.hash(\n-            json.dumps(\n-                {\"args\": args, \"kwargs\": kwargs},\n-                sort_keys=True,\n-                default=self.json_default,\n-            )\n-        )\n-\n-    async def set(self, args, result):\n-        is_new = args not in self.cache\n-        self.cache[args] = result\n-        await self.update_stat(args, \"set\")\n-        try:\n-            self.lru.pop(self.lru.index(args))\n-        except (ValueError, IndexError):\n-            pass\n-        self.lru.insert(0, args)\n-\n-        while len(self.lru) > self.max_items:\n-            self.cache.pop(self.lru[-1])\n-            self.lru.pop()\n-\n-        if is_new:\n-            self.version += 1\n-\n-    async def delete(self, args):\n-        await self.update_stat(args, \"delete\")\n-        if args in self.cache:\n-            try:\n-                self.lru.pop(self.lru.index(args))\n-            except IndexError:\n-                pass\n-            del self.cache[args]\n-\n-    def async_cache(self, func):\n-        @functools.wraps(func)\n-        async def wrapper(*args, **kwargs):\n-            cache_key = await self.create_cache_key(args, kwargs)\n-            cached = await self.get(cache_key)\n-            if cached:\n-                return cached\n-            result = await func(*args, **kwargs)\n-            await self.set(cache_key, result)\n-            return result\n-\n-        return wrapper\n-\n-    def async_delete_cache(self, func):\n-        @functools.wraps(func)\n-        async def wrapper(*args, **kwargs):\n-            cache_key = await self.create_cache_key(args, kwargs)\n-            if cache_key in self.cache:\n-                try:\n-                    self.lru.pop(self.lru.index(cache_key))\n-                except IndexError:\n-                    pass\n-                del self.cache[cache_key]\n-            return await func(*args, **kwargs)\n-\n-        return wrapper\n-\n-\n+\tdef __init__(A,app,max_items=CACHE_MAX_ITEMS_DEFAULT):A.app=app;A.cache={};A.max_items=max_items;A.stats={};A.lru=[];A.version=15505\n+\tasync def get(A,args):\n+\t\tB=args;await A.update_stat(B,_A)\n+\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\texcept:return\n+\t\tA.lru.insert(0,B)\n+\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n+\t\treturn A.cache[B]\n+\tasync def get_stats(A):\n+\t\tC=[]\n+\t\tfor B in A.lru:C.append({'key':B,_B:A.stats[B][_B],_A:A.stats[B][_A],_C:A.stats[B][_C],'value':str(A.serialize(A.cache[B].record))})\n+\t\treturn C\n+\tdef serialize(C,obj):B=None;A=obj.copy();A.pop('created_at',B);A.pop('deleted_at',B);A.pop('email',B);A.pop('password',B);return A\n+\tasync def update_stat(A,key,action):\n+\t\tC=action;B=key\n+\t\tif B not in A.stats:A.stats[B]={_B:0,_A:0,_C:0}\n+\t\tA.stats[B][C]=A.stats[B][C]+1\n+\tdef json_default(B,value):\n+\t\tA=value\n+\t\ttry:return json.dumps(A.__dict__,default=str)\n+\t\texcept:return str(A)\n+\tasync def create_cache_key(A,args,kwargs):return await security.hash(json.dumps({'args':args,'kwargs':kwargs},sort_keys=True,default=A.json_default))\n+\tasync def set(A,args,result):\n+\t\tB=args;C=B not in A.cache;A.cache[B]=result;await A.update_stat(B,_B)\n+\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\texcept(ValueError,IndexError):pass\n+\t\tA.lru.insert(0,B)\n+\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n+\t\tif C:A.version+=1\n+\tasync def delete(A,args):\n+\t\tB=args;await A.update_stat(B,_C)\n+\t\tif B in A.cache:\n+\t\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\t\texcept IndexError:pass\n+\t\t\tdel A.cache[B]\n+\tdef async_cache(A,func):\n+\t\t@functools.wraps(func)\n+\t\tasync def B(*B,**C):\n+\t\t\tD=await A.create_cache_key(B,C);E=await A.get(D)\n+\t\t\tif E:return E\n+\t\t\tF=await func(*B,**C);await A.set(D,F);return F\n+\t\treturn B\n+\tdef async_delete_cache(A,func):\n+\t\t@functools.wraps(func)\n+\t\tasync def B(*C,**D):\n+\t\t\tB=await A.create_cache_key(C,D)\n+\t\t\tif B in A.cache:\n+\t\t\t\ttry:A.lru.pop(A.lru.index(B))\n+\t\t\t\texcept IndexError:pass\n+\t\t\t\tdel A.cache[B]\n+\t\t\treturn await func(*C,**D)\n+\t\treturn B\n def async_cache(func):\n-    cache = {}\n-\n-    @functools.wraps(func)\n-    async def wrapper(*args):\n-        if args in cache:\n-            return cache[args]\n-        result = await func(*args)\n-        cache[args] = result\n-        return result\n-\n-    return wrapper\n+\tB={}\n+\t@functools.wraps(func)\n+\tasync def A(*A):\n+\t\tif A in B:return B[A]\n+\t\tC=await func(*A);B[A]=C;return C\n+\treturn A\n\\ No newline at end of file\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex f4cf2d3..0ec782b 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,120 +1,32 @@\n-\n-\n-\n-\n+_B='fields'\n+_A=None\n from snek.system import model\n-\n-\n class HTMLElement(model.ModelField):\n-    def __init__(\n-        self,\n-        id=None,\n-        tag=\"div\",\n-        name=None,\n-        html=None,\n-        class_name=None,\n-        text=None,\n-        *args,\n-        **kwargs,\n-    ):\n-        self.tag = tag\n-        self.text = text\n-        self.id = id\n-        self.class_name = class_name or name\n-        self.html = html\n-        super().__init__(name=name, *args, **kwargs)\n-\n-    async def to_json(self):\n-        result = await super().to_json()\n-        result[\"text\"] = self.text\n-        result[\"id\"] = self.id\n-        result[\"html\"] = self.html\n-        result[\"class_name\"] = self.class_name\n-        result[\"tag\"] = self.tag\n-        return result\n-\n-\n-class FormElement(HTMLElement):\n-    pass\n-\n-\n+\tdef __init__(A,id=_A,tag='div',name=_A,html=_A,class_name=_A,text=_A,*B,**C):A.tag=tag;A.text=text;A.id=id;A.class_name=class_name or name;A.html=html;super().__init__(*B,name=name,**C)\n+\tasync def to_json(B):A=await super().to_json();A['text']=B.text;A['id']=B.id;A['html']=B.html;A['class_name']=B.class_name;A['tag']=B.tag;return A\n+class FormElement(HTMLElement):0\n class FormInputElement(FormElement):\n-    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n-        super().__init__(tag=\"input\", *args, **kwargs)\n-        self.place_holder = place_holder\n-        self.type = type\n-\n-    async def to_json(self):\n-        data = await super().to_json()\n-        data[\"place_holder\"] = self.place_holder\n-        data[\"type\"] = self.type\n-        return data\n-\n-\n+\tdef __init__(A,type='text',place_holder=_A,*B,**C):super().__init__(*B,tag='input',**C);A.place_holder=place_holder;A.type=type\n+\tasync def to_json(B):A=await super().to_json();A['place_holder']=B.place_holder;A['type']=B.type;return A\n class FormButtonElement(FormElement):\n-    def __init__(self, tag=\"button\", *args, **kwargs):\n-        super().__init__(tag=tag, *args, **kwargs)\n-\n-\n+\tdef __init__(C,tag='button',*A,**B):super().__init__(*A,tag=tag,**B)\n class Form(model.BaseModel):\n-    @property\n-    def html_elements(self):\n-        return [element for element in self.fields if isinstance(element, HTMLElement)]\n-\n-    def set_user_data(self, data):\n-        return super().set_user_data(data.get(\"fields\"))\n-\n-    async def to_json(self, encode=False):\n-        elements = await super().to_json()\n-        html_elements = {}\n-        for element in elements.keys():\n-            if element == \"is_valid\":\n-                continue\n-            field = getattr(self, element)\n-            if isinstance(field, HTMLElement):\n-                try:\n-                    html_elements[element] = elements[element]\n-                except KeyError:\n-                    pass\n-\n-        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n-        return {\n-            \"fields\": html_elements,\n-            \"is_valid\": is_valid,\n-            \"errors\": await self.errors,\n-        }\n-\n-    @property\n-    async def errors(self):\n-        result = []\n-        for field in self.html_elements:\n-            result += await field.errors\n-        return result\n-\n-    @property\n-    async def is_valid(self):\n-        return False\n+\t@property\n+\tdef html_elements(self):return[A for A in self.fields if isinstance(A,HTMLElement)]\n+\tdef set_user_data(A,data):return super().set_user_data(data.get(_B))\n+\tasync def to_json(D,encode=False):\n+\t\tB='is_valid';E=await super().to_json();C={}\n+\t\tfor A in E.keys():\n+\t\t\tif A==B:continue\n+\t\t\tF=getattr(D,A)\n+\t\t\tif isinstance(F,HTMLElement):\n+\t\t\t\ttry:C[A]=E[A]\n+\t\t\t\texcept KeyError:pass\n+\t\tG=all(A[B]for A in C.values());return{_B:C,B:G,'errors':await D.errors}\n+\t@property\n+\tasync def errors(self):\n+\t\tA=[]\n+\t\tfor B in self.html_elements:A+=await B.errors\n+\t\treturn A\n+\t@property\n+\tasync def is_valid(self):return False\n\\ No newline at end of file\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex a1e87a4..fa993d5 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,110 +1,44 @@\n-\n-\n-\n-\n-\n-import asyncio\n-import pathlib\n-import uuid\n-import zlib\n+import asyncio,pathlib,uuid,zlib\n from urllib.parse import urljoin\n-\n-import aiohttp\n-import imgkit\n+import aiohttp,imgkit\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n-\n-\n async def crc32(data):\n-    try:\n-        data = data.encode()\n-    except:\n-        pass\n-    return \"crc32\" + str(zlib.crc32(data))\n-\n-\n-async def get_file(name, suffix=\".cache\"):\n-    name = await crc32(name)\n-    path = pathlib.Path(\".\").joinpath(\"cache\")\n-    if not path.exists():\n-        path.mkdir(parents=True, exist_ok=True)\n-    return path.joinpath(name + suffix)\n-\n-\n-async def public_touch(name=None):\n-    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n-    path.open(\"wb\").close()\n-    return path\n-\n-\n+\tA=data\n+\ttry:A=A.encode()\n+\texcept:pass\n+\treturn'crc32'+str(zlib.crc32(A))\n+async def get_file(name,suffix='.cache'):\n+\tA=name;A=await crc32(A);B=pathlib.Path('.').joinpath('cache')\n+\tif not B.exists():B.mkdir(parents=True,exist_ok=True)\n+\treturn B.joinpath(A+suffix)\n+async def public_touch(name=None):A=pathlib.Path('.').joinpath(str(uuid.uuid4())+name);A.open('wb').close();return A\n async def create_site_photo(url):\n-    loop = asyncio.get_event_loop()\n-    if not url.startswith(\"https\"):\n-    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n-\n-    if output_path.exists():\n-        return output_path\n-    output_path.touch()\n-\n-    def make_photo():\n-        imgkit.from_url(url, output_path.absolute())\n-        return output_path\n-\n-    return await loop.run_in_executor(None, make_photo)\n-\n-\n-async def repair_links(base_url, html_content):\n-    soup = BeautifulSoup(html_content, \"html.parser\")\n-    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n-        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n-            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n-        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n-            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n-    return soup.prettify()\n-\n-\n-async def is_html_content(content: bytes):\n-    if not content:\n-        return False\n-    try:\n-        content = content.decode(errors=\"ignore\")\n-    except:\n-        pass\n-    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n-    content = content.lower()\n-    for mark in marks:\n-        if mark in content:\n-            return True\n-    return False\n-\n-\n+\tA=url;C=asyncio.get_event_loop()\n+\tB=await get_file('site-screenshot-'+A,'.png')\n+\tif B.exists():return B\n+\tB.touch()\n+\tdef D():imgkit.from_url(A,B.absolute());return B\n+\treturn await C.run_in_executor(None,D)\n+async def repair_links(base_url,html_content):\n+\tD='http';E=base_url;B='src';C='href';F=BeautifulSoup(html_content,'html.parser')\n+\tfor A in F.find_all(['a','img','link']):\n+\t\tif A.has_attr(C)and not A[C].startswith(D):A[C]=urljoin(E,A[C])\n+\t\tif A.has_attr(B)and not A[B].startswith(D):A[B]=urljoin(E,A[B])\n+\treturn F.prettify()\n+async def is_html_content(content):\n+\tB=False;A=content\n+\tif not A:return B\n+\ttry:A=A.decode(errors='ignore')\n+\texcept:pass\n+\tC=['<html','<img','<p','<span','<div'];A=A.lower()\n+\tfor D in C:\n+\t\tif D in A:return True\n+\treturn B\n @time_cache_async(120)\n async def get(url):\n-    async with aiohttp.ClientSession() as session:\n-        response = await session.get(url)\n-        content = await response.text()\n-        if await is_html_content(content):\n-            content = (await repair_links(url, content)).encode()\n-        return content\n+\tasync with aiohttp.ClientSession()as B:\n+\t\tC=await B.get(url);A=await C.text()\n+\t\tif await is_html_content(A):A=(await repair_links(url,A)).encode()\n+\t\treturn A\n\\ No newline at end of file\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex 4a59024..beb313e 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,70 +1,37 @@\n-DEFAULT_LIMIT = 30\n+_A='uid'\n+DEFAULT_LIMIT=30\n import typing\n-\n from snek.system.model import BaseModel\n-\n-\n class BaseMapper:\n-\n-    model_class: BaseModel = None\n-    default_limit: int = DEFAULT_LIMIT\n-    table_name: str = None\n-\n-    def __init__(self, app):\n-        self.app = app\n-\n-        self.default_limit = self.__class__.default_limit\n-\n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    async def new(self):\n-        return self.model_class(mapper=self, app=self.app)\n-\n-    @property\n-    def table(self):\n-        return self.db[self.table_name]\n-\n-    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n-        if uid:\n-            kwargs[\"uid\"] = uid\n-        record = self.table.find_one(**kwargs)\n-        if not record:\n-            return None\n-        record = dict(record)\n-        model = await self.new()\n-        for key, value in record.items():\n-            model[key] = value\n-        return model\n-        return await self.model_class.from_record(mapper=self, record=record)\n-\n-    async def exists(self, **kwargs):\n-        return self.table.exists(**kwargs)\n-\n-    async def count(self, **kwargs) -> int:\n-        return self.table.count(**kwargs)\n-\n-    async def save(self, model: BaseModel) -> bool:\n-        if not model.record.get(\"uid\"):\n-            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n-        model.updated_at.update()\n-        return self.table.upsert(model.record, [\"uid\"])\n-\n-    async def find(self, **kwargs) -> typing.AsyncGenerator:\n-        if not kwargs.get(\"_limit\"):\n-            kwargs[\"_limit\"] = self.default_limit\n-        for record in self.table.find(**kwargs):\n-            model = await self.new()\n-            for key, value in record.items():\n-                model[key] = value\n-            yield model\n-\n-    async def query(self, sql, *args):\n-        for record in self.db.query(sql, *args):\n-            yield dict(record)\n-\n-    async def delete(self, **kwargs) -> int:\n-        if not kwargs or not isinstance(kwargs, dict):\n-            raise Exception(\"Can't execute delete with no filter.\")\n-        return self.table.delete(**kwargs)\n+\tmodel_class:BaseModel=None;default_limit:int=DEFAULT_LIMIT;table_name:str=None\n+\tdef __init__(A,app):A.app=app;A.default_limit=A.__class__.default_limit\n+\t@property\n+\tdef db(self):return self.app.db\n+\tasync def new(A):return A.model_class(mapper=A,app=A.app)\n+\t@property\n+\tdef table(self):return self.db[self.table_name]\n+\tasync def get(B,uid=None,**C):\n+\t\tif uid:C[_A]=uid\n+\t\tA=B.table.find_one(**C)\n+\t\tif not A:return\n+\t\tA=dict(A);D=await B.new()\n+\t\tfor(E,F)in A.items():D[E]=F\n+\t\treturn D;return await B.model_class.from_record(mapper=B,record=A)\n+\tasync def exists(A,**B):return A.table.exists(**B)\n+\tasync def count(A,**B):return A.table.count(**B)\n+\tasync def save(B,model):\n+\t\tA=model\n+\t\tif not A.record.get(_A):raise Exception(f\"Attempt to save without uid: {A.record}.\")\n+\t\tA.updated_at.update();return B.table.upsert(A.record,[_A])\n+\tasync def find(A,**B):\n+\t\tC='_limit'\n+\t\tif not B.get(C):B[C]=A.default_limit\n+\t\tfor E in A.table.find(**B):\n+\t\t\tD=await A.new()\n+\t\t\tfor(F,G)in E.items():D[F]=G\n+\t\t\tyield D\n+\tasync def query(A,sql,*B):\n+\t\tfor C in A.db.query(sql,*B):yield dict(C)\n+\tasync def delete(B,**A):\n+\t\tif not A or not isinstance(A,dict):raise Exception(\"Can't execute delete with no filter.\")\n+\t\treturn B.table.delete(**A)\n\\ No newline at end of file\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..b530fb8 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,87 +1,35 @@\n-\n+_A=True\n from types import SimpleNamespace\n-\n from app.cache import time_cache_async\n-from mistune import HTMLRenderer, Markdown\n+from mistune import HTMLRenderer,Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n-\n-\n class MarkdownRenderer(HTMLRenderer):\n-\n-    _allow_harmful_protocols = True\n-\n-    def __init__(self, app, template):\n-        self.template = template\n-\n-        self.app = app\n-        self.env = self.app.jinja2_env\n-        formatter = html.HtmlFormatter()\n-        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n-\n-    def _escape(self, str):\n-\n-    def get_lexer(self, lang, default=\"bash\"):\n-        try:\n-            return get_lexer_by_name(lang, stripall=True)\n-        except:\n-            return get_lexer_by_name(default, stripall=True)\n-\n-    def block_code(self, code, lang=None, info=None):\n-        if not lang:\n-            lang = info\n-        if not lang:\n-            lang = \"bash\"\n-        lexer = self.get_lexer(lang)\n-        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n-        result = highlight(code, lexer, formatter)\n-        return result\n-\n-    def render(self):\n-        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n-        renderer = MarkdownRenderer(self.app, self.template)\n-        markdown = Markdown(renderer=renderer)\n-        return markdown(markdown_string)\n-\n-\n-def render_markdown_sync(app, markdown_string):\n-    renderer = MarkdownRenderer(app, None)\n-    markdown = Markdown(renderer=renderer)\n-    return markdown(markdown_string)\n-\n-\n+\t_allow_harmful_protocols=_A\n+\tdef __init__(A,app,template):A.template=template;A.app=app;A.env=A.app.jinja2_env;B=html.HtmlFormatter();A.env.globals['highlight_styles']=B.get_style_defs()\n+\tdef _escape(A,str):return str\n+\tdef get_lexer(A,lang,default='bash'):\n+\t\ttry:return get_lexer_by_name(lang,stripall=_A)\n+\t\texcept:return get_lexer_by_name(default,stripall=_A)\n+\tdef block_code(B,code,lang=None,info=None):\n+\t\tA=lang\n+\t\tif not A:A=info\n+\t\tif not A:A='bash'\n+\t\tC=B.get_lexer(A);D=html.HtmlFormatter(lineseparator='<br>');E=highlight(code,C,D);return E\n+\tdef render(A):B=A.app.template_path.joinpath(A.template).read_text();C=MarkdownRenderer(A.app,A.template);D=Markdown(renderer=C);return D(B)\n+def render_markdown_sync(app,markdown_string):A=MarkdownRenderer(app,None);B=Markdown(renderer=A);return B(markdown_string)\n @time_cache_async(120)\n-async def render_markdown(app, markdown_string):\n-    return render_markdown_sync(app, markdown_string)\n-\n-\n-from jinja2 import TemplateSyntaxError, nodes\n+async def render_markdown(app,markdown_string):return render_markdown_sync(app,markdown_string)\n+from jinja2 import TemplateSyntaxError,nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-\n-\n class MarkdownExtension(Extension):\n-    tags = {\"markdown\"}\n-\n-    def __init__(self, environment):\n-        self.app = SimpleNamespace(jinja2_env=environment)\n-        super(MarkdownExtension, self).__init__(environment)\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        return render_markdown_sync(self.app, caller())\n+\ttags={'markdown'}\n+\tdef __init__(A,environment):B=environment;A.app=SimpleNamespace(jinja2_env=B);super(MarkdownExtension,A).__init__(B)\n+\tdef parse(D,parser):\n+\t\tA=parser;E=next(A.stream).lineno;B=[Const('')];C=''\n+\t\ttry:B=[A.parse_expression()]\n+\t\texcept TemplateSyntaxError:C=A.parse_statements(['name:endmarkdown'],drop_needle=_A)\n+\t\treturn nodes.CallBlock(D.call_method('_to_html',B),[],[],C).set_lineno(E)\n+\tdef _to_html(A,md_file,caller):return render_markdown_sync(A.app,caller())\n\\ No newline at end of file\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 3a9a055..1437a3f 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,53 +1,21 @@\n-\n-\n-\n-\n+_D='Access-Control-Allow-Credentials'\n+_C='Access-Control-Allow-Headers'\n+_B='Access-Control-Allow-Methods'\n+_A='Access-Control-Allow-Origin'\n from aiohttp import web\n-\n-\n @web.middleware\n-async def no_cors_middleware(request, handler):\n-    response = await handler(request)\n-    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n-    return response\n-\n-\n+async def no_cors_middleware(request,handler):A=await handler(request);A.headers.pop(_A,None);return A\n @web.middleware\n-async def cors_allow_middleware(request, handler):\n-    response = await handler(request)\n-    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = (\n-        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n-    )\n-    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-    return response\n-\n-\n+async def cors_allow_middleware(request,handler):A=await handler(request);A.headers[_A]='*';A.headers[_B]='GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND';A.headers[_C]='*';A.headers[_D]='true';return A\n @web.middleware\n-async def auth_middleware(request, handler):\n-    request[\"user\"] = None\n-    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n-        request[\"user\"] = await request.app.services.user.get(\n-            uid=request.app.session.get(\"uid\")\n-        )\n-    return await handler(request)\n-\n-\n+async def auth_middleware(request,handler):\n+\tB='uid';C='user';A=request;A[C]=None\n+\tif A.session.get(B)and A.session.get('logged_in'):A[C]=await A.app.services.user.get(uid=A.app.session.get(B))\n+\treturn await handler(A)\n @web.middleware\n-async def cors_middleware(request, handler):\n-    if request.headers.get(\"Allow\"):\n-        return await handler(request)\n-\n-    response = await handler(request)\n-    if request.headers.get(\"Allow\"):\n-        return response\n-    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n-    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n-    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n-    return response\n+async def cors_middleware(request,handler):\n+\tC='Allow';D=handler;B=request\n+\tif B.headers.get(C):return await D(B)\n+\tA=await D(B)\n+\tif B.headers.get(C):return A\n+\tA.headers[_A]='*';A.headers[_B]='GET, POST, PUT, DELETE, OPTIONS';A.headers[_C]='*';A.headers[_D]='true';return A\n\\ No newline at end of file\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex 9e9830d..b036329 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,377 +1,139 @@\n-\n-\n-\n-\n-\n-import copy\n-import json\n-import re\n-import uuid\n+_I='deleted_at'\n+_H='updated_at'\n+_G='created_at'\n+_F='is_valid'\n+_E='name'\n+_D=False\n+_C='value'\n+_B=True\n+_A=None\n+import copy,json,re,uuid\n from collections import OrderedDict\n-from datetime import datetime, timezone\n-\n-TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n-\n-\n-def now():\n-    return str(datetime.now(timezone.utc))\n-\n-\n-def add_attrs(**kwargs):\n-    def decorator(func):\n-        for key, value in kwargs.items():\n-            setattr(func, key, value)\n-        return func\n-\n-    return decorator\n-\n-\n-def validate_attrs(\n-    required=False, min_length=None, max_length=None, regex=None, **kwargs\n-):\n-    def decorator(func):\n-        return add_attrs(\n-            required=required,\n-            min_length=min_length,\n-            max_length=max_length,\n-            regex=regex,\n-            **kwargs,\n-        )(func)\n-\n-\n+from datetime import datetime,timezone\n+TIMESTAMP_REGEX='^\\\\d{4}-\\\\d{2}-\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{6}\\\\+\\\\d{2}:\\\\d{2}$'\n+def now():return str(datetime.now(timezone.utc))\n+def add_attrs(**A):\n+\tdef B(func):\n+\t\tfor(B,C)in A.items():setattr(func,B,C)\n+\t\treturn func\n+\treturn B\n+def validate_attrs(required=_D,min_length=_A,max_length=_A,regex=_A,**A):\n+\tdef B(func):return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**A)(func)\n class Validator:\n-    _index = 0\n-\n-    @property\n-    def value(self):\n-        return self._value\n-\n-    @value.setter\n-    def value(self, val):\n-        self._value = json.loads(json.dumps(val, default=str))\n-\n-    @property\n-    def initial_value(self):\n-        return self.value\n-\n-    def custom_validation(self):\n-        return True\n-\n-    def __init__(\n-        self,\n-        required=False,\n-        min_num=None,\n-        max_num=None,\n-        min_length=None,\n-        max_length=None,\n-        regex=None,\n-        value=None,\n-        kind=None,\n-        help_text=None,\n-        app=None,\n-        model=None,\n-        **kwargs,\n-    ):\n-        self.index = Validator._index\n-        Validator._index += 1\n-        self.app = app\n-        self.model = model\n-        self.required = required\n-        self.min_num = min_num\n-        self.max_num = max_num\n-        self.min_length = min_length\n-        self.max_length = max_length\n-        self.regex = regex\n-        self._value = None\n-        self.value = value\n-        self.kind = kind\n-        self.help_text = help_text\n-        self.__dict__.update(kwargs)\n-\n-    @property\n-    async def errors(self):\n-        error_list = []\n-        if self.value is None and self.required:\n-            error_list.append(\"Field is required.\")\n-            return error_list\n-\n-        if self.value is None:\n-            return error_list\n-\n-        if self.kind in [int, float]:\n-            if self.min_num is not None and self.value < self.min_num:\n-                error_list.append(f\"Field should be minimal {self.min_num}.\")\n-            if self.max_num is not None and self.value > self.max_num:\n-                error_list.append(f\"Field should be maximal {self.max_num}.\")\n-        if self.min_length is not None and len(self.value) < self.min_length:\n-            error_list.append(\n-                f\"Field should be minimal {self.min_length} characters long.\"\n-            )\n-        if self.max_length is not None and len(self.value) > self.max_length:\n-            error_list.append(\n-                f\"Field should be maximal {self.max_length} characters long.\"\n-            )\n-        if self.regex and self.value and not re.match(self.regex, self.value):\n-            error_list.append(\"Invalid value.\")\n-        if self.kind and not isinstance(self.value, self.kind):\n-            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n-        return error_list\n-\n-    async def validate(self):\n-        errors = await self.errors\n-        if errors:\n-            raise ValueError(f\"Errors: {errors}.\")\n-        return True\n-\n-    def __repr__(self):\n-        return str(self.to_json())\n-\n-    @property\n-    async def is_valid(self):\n-        try:\n-            await self.validate()\n-            return True\n-        except ValueError:\n-            return False\n-\n-    async def to_json(self):\n-        errors = await self.errors\n-        is_valid = await self.is_valid\n-        return {\n-            \"required\": self.required,\n-            \"min_num\": self.min_num,\n-            \"max_num\": self.max_num,\n-            \"min_length\": self.min_length,\n-            \"max_length\": self.max_length,\n-            \"regex\": self.regex,\n-            \"value\": self.value,\n-            \"kind\": str(self.kind),\n-            \"help_text\": self.help_text,\n-            \"errors\": errors,\n-            \"is_valid\": is_valid,\n-            \"index\": self.index,\n-        }\n-\n-\n+\t_index=0\n+\t@property\n+\tdef value(self):return self._value\n+\t@value.setter\n+\tdef value(self,val):self._value=json.loads(json.dumps(val,default=str))\n+\t@property\n+\tdef initial_value(self):return self.value\n+\tdef custom_validation(A):return _B\n+\tdef __init__(A,required=_D,min_num=_A,max_num=_A,min_length=_A,max_length=_A,regex=_A,value=_A,kind=_A,help_text=_A,app=_A,model=_A,**B):A.index=Validator._index;Validator._index+=1;A.app=app;A.model=model;A.required=required;A.min_num=min_num;A.max_num=max_num;A.min_length=min_length;A.max_length=max_length;A.regex=regex;A._value=_A;A.value=value;A.kind=kind;A.help_text=help_text;A.__dict__.update(B)\n+\t@property\n+\tasync def errors(self):\n+\t\tA=self;B=[]\n+\t\tif A.value is _A and A.required:B.append('Field is required.');return B\n+\t\tif A.value is _A:return B\n+\t\tif A.kind in[int,float]:\n+\t\t\tif A.min_num is not _A and A.value<A.min_num:B.append(f\"Field should be minimal {A.min_num}.\")\n+\t\t\tif A.max_num is not _A and A.value>A.max_num:B.append(f\"Field should be maximal {A.max_num}.\")\n+\t\tif A.min_length is not _A and len(A.value)<A.min_length:B.append(f\"Field should be minimal {A.min_length} characters long.\")\n+\t\tif A.max_length is not _A and len(A.value)>A.max_length:B.append(f\"Field should be maximal {A.max_length} characters long.\")\n+\t\tif A.regex and A.value and not re.match(A.regex,A.value):B.append('Invalid value.')\n+\t\tif A.kind and not isinstance(A.value,A.kind):B.append(f\"Invalid kind. It is supposed to be {A.kind}.\")\n+\t\treturn B\n+\tasync def validate(B):\n+\t\tA=await B.errors\n+\t\tif A:raise ValueError(f\"Errors: {A}.\")\n+\t\treturn _B\n+\tdef __repr__(A):return str(A.to_json())\n+\t@property\n+\tasync def is_valid(self):\n+\t\ttry:await self.validate();return _B\n+\t\texcept ValueError:return _D\n+\tasync def to_json(A):B=await A.errors;C=await A.is_valid;return{'required':A.required,'min_num':A.min_num,'max_num':A.max_num,'min_length':A.min_length,'max_length':A.max_length,'regex':A.regex,_C:A.value,'kind':str(A.kind),'help_text':A.help_text,'errors':B,_F:C,'index':A.index}\n class ModelField(Validator):\n-\n-    index = 1\n-\n-    def __init__(self, name=None, save=True, *args, **kwargs):\n-        self.name = name\n-        self.save = save\n-        super().__init__(*args, **kwargs)\n-\n-    async def to_json(self):\n-        result = await super().to_json()\n-        result[\"name\"] = self.name\n-        return result\n-\n-\n+\tindex=1\n+\tdef __init__(A,name=_A,save=_B,*B,**C):A.name=name;A.save=save;super().__init__(*B,**C)\n+\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;return A\n class CreatedField(ModelField):\n-\n-    @property\n-    def initial_value(self):\n-        return now()\n-\n-    def update(self):\n-        if not self.value:\n-            self.value = now()\n-\n-\n+\t@property\n+\tdef initial_value(self):return now()\n+\tdef update(A):\n+\t\tif not A.value:A.value=now()\n class UpdatedField(ModelField):\n-\n-    def update(self):\n-        self.value = now()\n-\n-\n+\tdef update(A):A.value=now()\n class DeletedField(ModelField):\n-\n-    def update(self):\n-        self.value = now()\n-\n-\n+\tdef update(A):A.value=now()\n class UUIDField(ModelField):\n-\n-    @property\n-    def value(self):\n-        return str(self._value)\n-\n-    @value.setter\n-    def value(self, val):\n-        self._value = str(val)\n-\n-    @property\n-    def initial_value(self):\n-        return str(uuid.uuid4())\n-\n-\n+\t@property\n+\tdef value(self):return str(self._value)\n+\t@value.setter\n+\tdef value(self,val):self._value=str(val)\n+\t@property\n+\tdef initial_value(self):return str(uuid.uuid4())\n class BaseModel:\n-\n-    uid = UUIDField(name=\"uid\", required=True)\n-    created_at = CreatedField(\n-        name=\"created_at\",\n-        required=True,\n-        regex=TIMESTAMP_REGEX,\n-        place_holder=\"Created at\",\n-    )\n-    updated_at = UpdatedField(\n-        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n-    )\n-    deleted_at = DeletedField(\n-        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n-    )\n-\n-    @classmethod\n-    async def from_record(cls, record, mapper):\n-        model = cls()\n-        model.mapper = mapper\n-        model.record = record\n-        return model\n-\n-    @property\n-    def mapper(self):\n-        return self._mapper\n-\n-    @mapper.setter\n-    def mapper(self, value):\n-        self._mapper = value\n-\n-    @property\n-    def record(self):\n-        return {key: field.value for key, field in self.fields.items()}\n-\n-    @record.setter\n-    def record(self, val):\n-        for key, value in val.items():\n-            field = self.fields.get(key)\n-            if not field:\n-                continue\n-            self[key] = value\n-        return self\n-\n-    def __init__(self, *args, **kwargs):\n-        self._mapper = kwargs.get(\"mapper\")\n-        self.app = kwargs.get(\"app\")\n-        self.fields = {}\n-        for key in dir(self.__class__):\n-            obj = getattr(self.__class__, key)\n-\n-            if isinstance(obj, Validator):\n-                self.__dict__[key] = copy.deepcopy(obj)\n-                self.__dict__[key].value = kwargs.pop(\n-                    key, self.__dict__[key].initial_value\n-                )\n-                self.fields[key] = self.__dict__[key]\n-                self.fields[key].model = self\n-                self.fields[key].app = kwargs.get(\"app\")\n-\n-    def __setitem__(self, key, value):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            obj.value = value\n-\n-    def __getattr__(self, key):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            return obj.value\n-        return obj\n-\n-    def set_user_data(self, data):\n-        for key, value in data.items():\n-            field = self.fields.get(key)\n-            if not field:\n-                continue\n-            if value.get(\"name\"):\n-                value = value.get(\"value\")\n-            field.value = value\n-\n-    @property\n-    async def is_valid(self):\n-        return all([await field.is_valid for field in self.fields.values()])\n-\n-    def __getitem__(self, key):\n-        obj = self.__dict__.get(key)\n-        if isinstance(obj, Validator):\n-            return obj.value\n-\n-    def __setattr__(self, key, value):\n-        obj = getattr(self, key)\n-        if isinstance(obj, Validator):\n-            obj.value = value\n-        else:\n-            self.__dict__[key] = value\n-\n-    @property\n-    async def recordz(self):\n-        obj = await self.to_json()\n-        record = {}\n-        for key, value in obj.items():\n-            if not isinstance(value, dict) or \"value\" not in value:\n-                continue\n-            if getattr(self, key).save:\n-                record[key] = value.get(\"value\")\n-        return record\n-\n-    async def to_json(self, encode=False):\n-        model_data = OrderedDict(\n-            {\n-                \"uid\": self.uid.value,\n-                \"created_at\": self.created_at.value,\n-                \"updated_at\": self.updated_at.value,\n-                \"deleted_at\": self.deleted_at.value,\n-                \"is_valid\": await self.is_valid,\n-            }\n-        )\n-\n-        for key, value in self.fields.items():\n-            if key == \"record\":\n-                continue\n-            value = self.__dict__[key]\n-            if hasattr(value, \"value\"):\n-                model_data[key] = await value.to_json()\n-        if encode:\n-            return json.dumps(model_data, indent=2)\n-        return model_data\n-\n-\n+\tuid=UUIDField(name='uid',required=_B);created_at=CreatedField(name=_G,required=_B,regex=TIMESTAMP_REGEX,place_holder='Created at');updated_at=UpdatedField(name=_H,regex=TIMESTAMP_REGEX,place_holder='Updated at');deleted_at=DeletedField(name=_I,regex=TIMESTAMP_REGEX,place_holder='Deleted at')\n+\t@classmethod\n+\tasync def from_record(B,record,mapper):A=B();A.mapper=mapper;A.record=record;return A\n+\t@property\n+\tdef mapper(self):return self._mapper\n+\t@mapper.setter\n+\tdef mapper(self,value):self._mapper=value\n+\t@property\n+\tdef record(self):return{A:B.value for(A,B)in self.fields.items()}\n+\t@record.setter\n+\tdef record(self,val):\n+\t\tA=self\n+\t\tfor(B,C)in val.items():\n+\t\t\tD=A.fields.get(B)\n+\t\t\tif not D:continue\n+\t\t\tA[B]=C\n+\t\treturn A\n+\tdef __init__(A,*F,**C):\n+\t\tD='app';A._mapper=C.get('mapper');A.app=C.get(D);A.fields={}\n+\t\tfor B in dir(A.__class__):\n+\t\t\tE=getattr(A.__class__,B)\n+\t\t\tif isinstance(E,Validator):A.__dict__[B]=copy.deepcopy(E);A.__dict__[B].value=C.pop(B,A.__dict__[B].initial_value);A.fields[B]=A.__dict__[B];A.fields[B].model=A;A.fields[B].app=C.get(D)\n+\tdef __setitem__(B,key,value):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):A.value=value\n+\tdef __getattr__(B,key):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):return A.value\n+\t\treturn A\n+\tdef set_user_data(C,data):\n+\t\tfor(D,A)in data.items():\n+\t\t\tB=C.fields.get(D)\n+\t\t\tif not B:continue\n+\t\t\tif A.get(_E):A=A.get(_C)\n+\t\t\tB.value=A\n+\t@property\n+\tasync def is_valid(self):return all([await A.is_valid for A in self.fields.values()])\n+\tdef __getitem__(B,key):\n+\t\tA=B.__dict__.get(key)\n+\t\tif isinstance(A,Validator):return A.value\n+\tdef __setattr__(A,key,value):\n+\t\tB=value;C=getattr(A,key)\n+\t\tif isinstance(C,Validator):C.value=B\n+\t\telse:A.__dict__[key]=B\n+\t@property\n+\tasync def recordz(self):\n+\t\tD=await self.to_json();B={}\n+\t\tfor(C,A)in D.items():\n+\t\t\tif not isinstance(A,dict)or _C not in A:continue\n+\t\t\tif getattr(self,C).save:B[C]=A.get(_C)\n+\t\treturn B\n+\tasync def to_json(A,encode=_D):\n+\t\tB=OrderedDict({'uid':A.uid.value,_G:A.created_at.value,_H:A.updated_at.value,_I:A.deleted_at.value,_F:await A.is_valid})\n+\t\tfor(C,D)in A.fields.items():\n+\t\t\tif C=='record':continue\n+\t\t\tD=A.__dict__[C]\n+\t\t\tif hasattr(D,_C):B[C]=await D.to_json()\n+\t\tif encode:return json.dumps(B,indent=2)\n+\t\treturn B\n class FormElement(ModelField):\n-\n-    def __init__(self, place_holder=None, *args, **kwargs):\n-        super().__init__(*args, **kwargs)\n-        self.place_holder = place_holder\n-\n-\n+\tdef __init__(A,place_holder=_A,*B,**C):super().__init__(*B,**C);A.place_holder=place_holder\n class FormElement(ModelField):\n-\n-    def __init__(self, place_holder=None, *args, **kwargs):\n-        self.place_holder = place_holder\n-        super().__init__(*args, **kwargs)\n-\n-    async def to_json(self):\n-        data = await super().to_json()\n-        data[\"name\"] = self.name\n-        data[\"place_holder\"] = self.place_holder\n-        return data\n+\tdef __init__(A,place_holder=_A,*B,**C):A.place_holder=place_holder;super().__init__(*B,**C)\n+\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;A['place_holder']=B.place_holder;return A\n\\ No newline at end of file\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex f91ec42..a36bb76 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,13 +1,7 @@\n class Object:\n-\n-    def __init__(self, *args, **kwargs):\n-        for arg in args:\n-            if isinstance(arg, dict):\n-                self.__dict__.update(arg)\n-        self.__dict__.update(kwargs)\n-\n-    def __getitem__(self, key):\n-        return self.__dict__[key]\n-\n-    def __setitem__(self, key, value):\n-        self.__dict__[key] = value\n+\tdef __init__(A,*C,**D):\n+\t\tfor B in C:\n+\t\t\tif isinstance(B,dict):A.__dict__.update(B)\n+\t\tA.__dict__.update(D)\n+\tdef __getitem__(A,key):return A.__dict__[key]\n+\tdef __setitem__(A,key,value):A.__dict__[key]=value\n\\ No newline at end of file\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex e0e5542..d196b30 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,46 +1,17 @@\n-import cProfile\n-import pstats\n-import sys\n-\n+import cProfile,pstats,sys\n from aiohttp import web\n-\n-profiler = None\n+profiler=None\n import io\n-\n-\n @web.middleware\n-async def profile_middleware(request, handler):\n-    global profiler\n-    if not profiler:\n-        profiler = cProfile.Profile()\n-    profiler.enable()\n-    response = await handler(request)\n-    profiler.disable()\n-    stats = pstats.Stats(profiler, stream=sys.stdout)\n-    stats.sort_stats(\"cumulative\")\n-    stats.print_stats()\n-    return response\n-\n-\n-async def profiler_handler(request):\n-    output = io.StringIO()\n-    stats = pstats.Stats(profiler, stream=output)\n-    sort_by = request.query.get(\"sort\", \"tot. percall\")\n-    stats.sort_stats(sort_by)\n-    stats.print_stats()\n-    return web.Response(text=output.getvalue())\n-\n-\n+async def profile_middleware(request,handler):\n+\tglobal profiler\n+\tif not profiler:profiler=cProfile.Profile()\n+\tprofiler.enable();B=await handler(request);profiler.disable();A=pstats.Stats(profiler,stream=sys.stdout);A.sort_stats('cumulative');A.print_stats();return B\n+async def profiler_handler(request):A=io.StringIO();B=pstats.Stats(profiler,stream=A);C=request.query.get('sort','tot. percall');B.sort_stats(C);B.print_stats();return web.Response(text=A.getvalue())\n class Profiler:\n-\n-    def __init__(self):\n-        global profiler\n-        if profiler is None:\n-            profiler = cProfile.Profile()\n-        self.profiler = profiler\n-\n-    async def __aenter__(self):\n-        self.profiler.enable()\n-\n-    async def __aexit__(self, *args, **kwargs):\n-        self.profiler.disable()\n+\tdef __init__(A):\n+\t\tglobal profiler\n+\t\tif profiler is None:profiler=cProfile.Profile()\n+\t\tA.profiler=profiler\n+\tasync def __aenter__(A):A.profiler.enable()\n+\tasync def __aexit__(A,*B,**C):A.profiler.disable()\n\\ No newline at end of file\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 43b61fe..8d5ced9 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,77 +1,24 @@\n-import hashlib\n-import uuid\n-\n-DEFAULT_SALT = \"snekker-de-snek-\"\n-DEFAULT_NS = \"snekker-de-snek-\"\n-\n-\n+_A='snekker-de-snek-'\n+import hashlib,uuid\n+DEFAULT_SALT=_A\n+DEFAULT_NS=_A\n class UIDNS:\n-    def __init__(self, name: str) -> None:\n-        \"\"\"Initialize UIDNS with a name.\"\"\"\n-        self.name = name\n-\n-    @property\n-    def bytes(self) -> bytes:\n-        \"\"\"Return the bytes representation of the name.\"\"\"\n-        return self.name.encode()\n-\n-\n-def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n-    \"\"\"Generate a UUID based on the provided value and namespace.\n-\n-    Args:\n-        value (str): The value to generate the UUID from. If None, a new UUID is created.\n-        ns (str): The namespace to use for UUID generation.\n-\n-    Returns:\n-        str: The generated UUID as a string.\n-    \"\"\"\n-    try:\n-        ns = ns.decode()\n-    except AttributeError:\n-        pass\n-    if not value:\n-        value = str(uuid.uuid4())\n-    try:\n-        value = value.decode()\n-    except AttributeError:\n-        pass\n-\n-    return str(uuid.uuid5(UIDNS(ns), value))\n-\n-\n-async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n-    \"\"\"Hash the given data with the specified salt using SHA-256.\n-\n-    Args:\n-        data (str): The data to hash.\n-        salt (str): The salt to use for hashing.\n-\n-    Returns:\n-        str: The hexadecimal representation of the hashed data.\n-    \"\"\"\n-    try:\n-        data = data.encode(errors=\"ignore\")\n-    except AttributeError:\n-        pass\n-    try:\n-        salt = salt.encode(errors=\"ignore\")\n-    except AttributeError:\n-        pass\n-    salted = salt + data\n-\n-    obj = hashlib.sha256(salted)\n-    return obj.hexdigest()\n-\n-\n-async def verify(string: str, hashed: str) -> bool:\n-    \"\"\"Verify if the given string matches the hashed value.\n-\n-    Args:\n-        string (str): The string to verify.\n-        hashed (str): The hashed value to compare against.\n-\n-    Returns:\n-        bool: True if the string matches the hashed value, False otherwise.\n-    \"\"\"\n-    return await hash(string) == hashed\n+\tdef __init__(A,name):'Initialize UIDNS with a name.';A.name=name\n+\t@property\n+\tdef bytes(self):'Return the bytes representation of the name.';return self.name.encode()\n+def uid(value=None,ns=DEFAULT_NS):\n+\t'Generate a UUID based on the provided value and namespace.\\n\\n    Args:\\n        value (str): The value to generate the UUID from. If None, a new UUID is created.\\n        ns (str): The namespace to use for UUID generation.\\n\\n    Returns:\\n        str: The generated UUID as a string.\\n    ';A=value\n+\ttry:ns=ns.decode()\n+\texcept AttributeError:pass\n+\tif not A:A=str(uuid.uuid4())\n+\ttry:A=A.decode()\n+\texcept AttributeError:pass\n+\treturn str(uuid.uuid5(UIDNS(ns),A))\n+async def hash(data,salt=DEFAULT_SALT):\n+\t'Hash the given data with the specified salt using SHA-256.\\n\\n    Args:\\n        data (str): The data to hash.\\n        salt (str): The salt to use for hashing.\\n\\n    Returns:\\n        str: The hexadecimal representation of the hashed data.\\n    ';C='ignore';A=salt;B=data\n+\ttry:B=B.encode(errors=C)\n+\texcept AttributeError:pass\n+\ttry:A=A.encode(errors=C)\n+\texcept AttributeError:pass\n+\tD=A+B;E=hashlib.sha256(D);return E.hexdigest()\n+async def verify(string,hashed):'Verify if the given string matches the hashed value.\\n\\n    Args:\\n        string (str): The string to verify.\\n        hashed (str): The hashed value to compare against.\\n\\n    Returns:\\n        bool: True if the string matches the hashed value, False otherwise.\\n    ';return await hash(string)==hashed\n\\ No newline at end of file\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex c6d2afc..eb735b1 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,67 +1,42 @@\n+_B='uid'\n+_A=None\n from snek.mapper import get_mapper\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n-\n-\n class BaseService:\n-\n-    mapper_name: BaseMapper = None\n-\n-    @property\n-    def services(self):\n-        return self.app.services\n-\n-    def __init__(self, app):\n-        self.app = app\n-        self.cache = app.cache\n-        if self.mapper_name:\n-            self.mapper = get_mapper(self.mapper_name, app=self.app)\n-        else:\n-            self.mapper = None\n-\n-    async def exists(self, uid=None, **kwargs):\n-        if uid:\n-            if not kwargs and await self.cache.get(uid):\n-                return True\n-            kwargs[\"uid\"] = uid\n-        return await self.count(**kwargs) > 0\n-\n-    async def count(self, **kwargs):\n-        return await self.mapper.count(**kwargs)\n-\n-    async def new(self, **kwargs):\n-        return await self.mapper.new()\n-\n-    async def query(self, sql, *args):\n-        for record in self.app.db.query(sql, *args):\n-            yield record\n-\n-    async def get(self, uid=None, **kwargs):\n-        if uid:\n-            if not kwargs:\n-                result = await self.cache.get(uid)\n-                if False and result and result.__class__ == self.mapper.model_class:\n-                    return result\n-            kwargs[\"uid\"] = uid\n-\n-        result = await self.mapper.get(**kwargs)\n-        if result:\n-            await self.cache.set(result[\"uid\"], result)\n-        return result\n-\n-    async def save(self, model: UserModel):\n-        if await self.mapper.save(model):\n-            await self.cache.set(model[\"uid\"], model)\n-            return True\n-        errors = await model.errors\n-        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n-\n-    async def find(self, **kwargs):\n-        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n-            kwargs[\"_limit\"] = 60\n-        async for model in self.mapper.find(**kwargs):\n-            yield model\n-\n-    async def delete(self, **kwargs):\n-        return await self.mapper.delete(**kwargs)\n+\tmapper_name:BaseMapper=_A\n+\t@property\n+\tdef services(self):return self.app.services\n+\tdef __init__(A,app):\n+\t\tA.app=app;A.cache=app.cache\n+\t\tif A.mapper_name:A.mapper=get_mapper(A.mapper_name,app=A.app)\n+\t\telse:A.mapper=_A\n+\tasync def exists(C,uid=_A,**A):\n+\t\tB=uid\n+\t\tif B:\n+\t\t\tif not A and await C.cache.get(B):return True\n+\t\t\tA[_B]=B\n+\t\treturn await C.count(**A)>0\n+\tasync def count(A,**B):return await A.mapper.count(**B)\n+\tasync def new(A,**B):return await A.mapper.new()\n+\tasync def query(A,sql,*B):\n+\t\tfor C in A.app.db.query(sql,*B):yield C\n+\tasync def get(B,uid=_A,**C):\n+\t\tD=uid\n+\t\tif D:\n+\t\t\tif not C:\n+\t\t\t\tA=await B.cache.get(D)\n+\t\t\t\tif False and A and A.__class__==B.mapper.model_class:return A\n+\t\t\tC[_B]=D\n+\t\tA=await B.mapper.get(**C)\n+\t\tif A:await B.cache.set(A[_B],A)\n+\t\treturn A\n+\tasync def save(B,model):\n+\t\tA=model\n+\t\tif await B.mapper.save(A):await B.cache.set(A[_B],A);return True\n+\t\tC=await A.errors;raise Exception(f\"Couldn't save model. Errors: f{C}\")\n+\tasync def find(C,**A):\n+\t\tB='_limit'\n+\t\tif B not in A or int(A.get(B))>30:A[B]=60\n+\t\tasync for D in C.mapper.find(**A):yield D\n+\tasync def delete(A,**B):return await A.mapper.delete(**B)\n\\ No newline at end of file\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d4b6819..1630219 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,249 +1,82 @@\n+_G=':snek1:'\n+_F='status'\n+_E='_to_html'\n+_D='alias'\n+_C=True\n+_B='html.parser'\n+_A='href'\n import re\n from types import SimpleNamespace\n-\n import emoji\n from bs4 import BeautifulSoup\n-from jinja2 import TemplateSyntaxError, nodes\n+from jinja2 import TemplateSyntaxError,nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-\n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n-    \"en\": \":snek1:\",\n-    \"status\": 2,\n-    \"E\": 0.6,\n-    \"alias\": [\":snek1:\"],\n-}\n-\n-emoji.EMOJI_DATA[\n-    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n-\"\"\"\n-] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n-\n-\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />']={'en':_G,_F:2,'E':.6,_D:[_G]}\n+emoji.EMOJI_DATA['\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n']={'en':':a1:',_F:2,'E':.6,_D:[':a1:']}\n def set_link_target_blank(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-\n-    for element in soup.find_all(\"a\"):\n-        element.attrs[\"target\"] = \"_blank\"\n-        element.attrs[\"rel\"] = \"noopener noreferrer\"\n-        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n-        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n-\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):element.attrs['target']='_blank';element.attrs['rel']='noopener noreferrer';element.attrs['referrerpolicy']='no-referrer';element.attrs[_A]=element.attrs[_A].strip('.').strip(',')\n+\treturn str(soup)\n def embed_youtube(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n-            if \"v=\" in element.attrs[\"href\"]:\n-                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n-            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\t\tvideo_name=element.attrs[_A].split('/')[-1]\n+\t\t\tif'v='in element.attrs[_A]:video_name=element.attrs[_A].split('?v=')[1].split('&')[0]\n+\treturn str(soup)\n def embed_image(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".png\",\n-            \".jpg\",\n-            \".jpeg\",\n-            \".gif\",\n-            \".webp\",\n-            \".svg\",\n-            \".bmp\",\n-            \".tiff\",\n-            \".ico\",\n-            \".heif\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\tfor extension in['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.tiff','.ico','.heif']:\n+\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<img src=\"{element.attrs[_A]}\" title=\"{element.attrs[_A]}\" alt=\"{element.attrs[_A]}\" />';element.replace_with(BeautifulSoup(embed_template,_B))\n+\treturn str(soup)\n def embed_media(text):\n-    soup = BeautifulSoup(text, \"html.parser\")\n-    for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".mp4\",\n-            \".mp3\",\n-            \".wav\",\n-            \".ogg\",\n-            \".webm\",\n-            \".flac\",\n-            \".aac\",\n-            \".mpg\",\n-            \".avi\",\n-            \".wmv\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n-    return str(soup)\n-\n-\n+\tsoup=BeautifulSoup(text,_B)\n+\tfor element in soup.find_all('a'):\n+\t\tfor extension in['.mp4','.mp3','.wav','.ogg','.webm','.flac','.aac','.mpg','.avi','.wmv']:\n+\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<video controls> <source src=\"{element.attrs[_A]}\">Your browser does not support the video tag.</video>';element.replace_with(BeautifulSoup(embed_template,_B))\n+\treturn str(soup)\n def linkify_https(text):\n-        return text\n-\n-    soup = BeautifulSoup(text, \"html.parser\")\n-\n-    for element in soup.find_all(text=True):\n-        parent = element.parent\n-        if parent.name in [\"a\", \"script\", \"style\"]:\n-            continue\n-\n-        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n-        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n-\n-    return set_link_target_blank(str(soup))\n-\n-\n+\tfor element in soup.find_all(text=_C):\n+\t\tparent=element.parent\n+\t\tif parent.name in['a','script','style']:continue\n+\t\tnew_text=re.sub(url_pattern,'<a href=\"\\\\g<0>\">\\\\g<0></a>',element);element.replace_with(BeautifulSoup(new_text,_B))\n+\treturn set_link_target_blank(str(soup))\n class EmojiExtension(Extension):\n-    tags = {\"emoji\"}\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        return emoji.emojize(caller(), language=\"alias\")\n-\n-\n+\ttags={'emoji'}\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endemoji'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):return emoji.emojize(caller(),language=_D)\n class LinkifyExtension(Extension):\n-    tags = {\"linkify\"}\n-\n-    def __init__(self, environment):\n-        self.app = SimpleNamespace(jinja2_env=environment)\n-        super(LinkifyExtension, self).__init__(environment)\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-        result = linkify_https(caller())\n-        result = embed_media(result)\n-        result = embed_image(result)\n-        result = embed_youtube(result)\n-        return result\n-\n-\n+\ttags={'linkify'}\n+\tdef __init__(self,environment):self.app=SimpleNamespace(jinja2_env=environment);super(LinkifyExtension,self).__init__(environment)\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endlinkify'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):result=linkify_https(caller());result=embed_media(result);result=embed_image(result);result=embed_youtube(result);return result\n class PythonExtension(Extension):\n-    tags = {\"py3\"}\n-\n-    def parse(self, parser):\n-        line_number = next(parser.stream).lineno\n-        md_file = [Const(\"\")]\n-        body = \"\"\n-        try:\n-            md_file = [parser.parse_expression()]\n-        except TemplateSyntaxError:\n-            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n-        return nodes.CallBlock(\n-            self.call_method(\"_to_html\", md_file), [], [], body\n-        ).set_lineno(line_number)\n-\n-    def _to_html(self, md_file, caller):\n-\n-        def fn(source):\n-            import subprocess\n-\n-            def system(command):\n-                if isinstance(command):\n-                    command = command.split(\" \")\n-                from io import StringIO\n-\n-                stdout = StringIO()\n-                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n-                return stdout.getvalue()\n-\n-            to_write = []\n-\n-            def render(text):\n-                global to_write\n-                to_write.append(text)\n-\n-            exec(source)\n-            return \"\".join(to_write)\n-\n-        return str(fn(caller()))\n+\ttags={'py3'}\n+\tdef parse(self,parser):\n+\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n+\t\ttry:md_file=[parser.parse_expression()]\n+\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endpy3'],drop_needle=_C)\n+\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n+\tdef _to_html(self,md_file,caller):\n+\t\tdef fn(source):\n+\t\t\timport subprocess\n+\t\t\tdef system(command):\n+\t\t\t\tif isinstance(command):command=command.split(' ')\n+\t\t\t\tfrom io import StringIO;stdout=StringIO();subprocess.run(command,stderr=stdout,stdout=stdout,text=_C);return stdout.getvalue()\n+\t\t\tto_write=[]\n+\t\t\tdef render(text):global to_write;to_write.append(text)\n+\t\t\texec(source);return''.join(to_write)\n+\t\treturn str(fn(caller()))\n\\ No newline at end of file\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex c5410b6..82207c7 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,113 +1,49 @@\n-import asyncio\n-import os\n-\n-try:\n-    import pty\n-except Exception as ex:\n-    print(\"You are not able to run a terminal. See error:\")\n-    print(ex)\n+_A=None\n+import asyncio,os\n+try:import pty\n+except Exception as ex:print('You are not able to run a terminal. See error:');print(ex)\n import subprocess\n-\n-commands = {\n-    \"alpine\": \"docker run -it alpine /bin/sh\",\n-    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n-}\n-\n-\n+commands={'alpine':'docker run -it alpine /bin/sh','r':'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh'}\n class TerminalSession:\n-    def __init__(self, command):\n-        self.master, self.slave = None, None\n-        self.process = None\n-        self.sockets = []\n-        self.history = b\"\"\n-        self.history_size = 1024 * 20\n-        self.command = command\n-        self.start_process(self.command)\n-\n-    def start_process(self, command):\n-        if not self.is_running():\n-            if self.master:\n-                os.close(self.master)\n-                os.close(self.slave)\n-                self.master = None\n-                self.slave = None\n-\n-            self.master, self.slave = pty.openpty()\n-            self.process = subprocess.Popen(\n-                command.split(\" \"),\n-                stdin=self.slave,\n-                stdout=self.slave,\n-                stderr=self.slave,\n-                bufsize=0,\n-                universal_newlines=True,\n-            )\n-\n-    def is_running(self):\n-        if not self.process:\n-            return False\n-        asyncio.get_event_loop()\n-        return self.process.poll() is None\n-\n-    async def add_websocket(self, ws):\n-        self.start_process(self.command)\n-        asyncio.create_task(self.read_output(ws))\n-\n-    async def read_output(self, ws):\n-        self.sockets.append(ws)\n-        if len(self.sockets) > 1 and self.history:\n-            start = 0\n-            try:\n-                start = self.history.index(b\"\\n\")\n-            except ValueError:\n-                pass\n-            await ws.send_bytes(self.history[start:])\n-            return\n-        loop = asyncio.get_event_loop()\n-        while True:\n-            try:\n-                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n-                if not data:\n-                    break\n-                self.history += data\n-                if len(self.history) > self.history_size:\n-                    self.history = self.history[: 0 - self.history_size]\n-                try:\n-                    for ws in self.sockets:\n-                except:\n-                    self.sockets.remove(ws)\n-            except Exception:\n-                await self.close()\n-                break\n-\n-    async def close(self):\n-        print(\"Terminating process\")\n-        if self.process:\n-            self.process.terminate()\n-            self.process = None\n-        if self.master:\n-            os.close(self.master)\n-            os.close(self.slave)\n-            self.master = None\n-            self.slave = None\n-\n-        print(\"Terminated process\")\n-        for ws in self.sockets:\n-            try:\n-                await ws.close()\n-            except Exception:\n-                pass\n-        self.sockets = []\n-\n-    async def write_input(self, data):\n-        try:\n-            data = data.encode()\n-        except AttributeError:\n-            pass\n-        try:\n-            await asyncio.get_event_loop().run_in_executor(\n-                None, os.write, self.master, data\n-            )\n-        except Exception as ex:\n-            print(ex)\n-            await self.close()\n+\tdef __init__(A,command):A.master,A.slave=_A,_A;A.process=_A;A.sockets=[];A.history=b'';A.history_size=20480;A.command=command;A.start_process(A.command)\n+\tdef start_process(A,command):\n+\t\tif not A.is_running():\n+\t\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n+\t\t\tA.master,A.slave=pty.openpty();A.process=subprocess.Popen(command.split(' '),stdin=A.slave,stdout=A.slave,stderr=A.slave,bufsize=0,universal_newlines=True)\n+\tdef is_running(A):\n+\t\tif not A.process:return False\n+\t\tasyncio.get_event_loop();return A.process.poll()is _A\n+\tasync def add_websocket(A,ws):A.start_process(A.command);asyncio.create_task(A.read_output(ws))\n+\tasync def read_output(A,ws):\n+\t\tB=ws;A.sockets.append(B)\n+\t\tif len(A.sockets)>1 and A.history:\n+\t\t\tD=0\n+\t\t\ttry:D=A.history.index(b'\\n')\n+\t\t\texcept ValueError:pass\n+\t\t\tawait B.send_bytes(A.history[D:]);return\n+\t\tE=asyncio.get_event_loop()\n+\t\twhile True:\n+\t\t\ttry:\n+\t\t\t\tC=await E.run_in_executor(_A,os.read,A.master,1024)\n+\t\t\t\tif not C:break\n+\t\t\t\tA.history+=C\n+\t\t\t\tif len(A.history)>A.history_size:A.history=A.history[:0-A.history_size]\n+\t\t\t\ttry:\n+\t\t\t\t\tfor B in A.sockets:await B.send_bytes(C)\n+\t\t\t\texcept:A.sockets.remove(B)\n+\t\t\texcept Exception:await A.close();break\n+\tasync def close(A):\n+\t\tprint('Terminating process')\n+\t\tif A.process:A.process.terminate();A.process=_A\n+\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n+\t\tprint('Terminated process')\n+\t\tfor B in A.sockets:\n+\t\t\ttry:await B.close()\n+\t\t\texcept Exception:pass\n+\t\tA.sockets=[]\n+\tasync def write_input(B,data):\n+\t\tA=data\n+\t\ttry:A=A.encode()\n+\t\texcept AttributeError:pass\n+\t\ttry:await asyncio.get_event_loop().run_in_executor(_A,os.write,B.master,A)\n+\t\texcept Exception as C:print(C);await B.close()\n\\ No newline at end of file\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex 70379ef..be19178 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,75 +1,31 @@\n from aiohttp import web\n-\n from snek.system.markdown import render_markdown\n-\n-\n class BaseView(web.View):\n-\n-    login_required = False\n-\n-    async def _iter(self):\n-        if self.login_required and (\n-            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n-        ):\n-            return web.HTTPFound(\"/\")\n-        return await super()._iter()\n-\n-    @property\n-    def base_url(self):\n-        return str(self.request.url.with_path(\"\").with_query(\"\"))\n-\n-    @property\n-    def app(self):\n-        return self.request.app\n-\n-    @property\n-    def db(self):\n-        return self.app.db\n-\n-    @property\n-    def services(self):\n-        return self.app.services\n-\n-    async def json_response(self, data, **kwargs):\n-        return web.json_response(data, **kwargs)\n-\n-    @property\n-    def session(self):\n-        return self.request.session\n-\n-    async def render_template(self, template_name, context=None):\n-        if template_name.endswith(\".md\"):\n-            response = await self.request.app.render_template(\n-                template_name, self.request, context\n-            )\n-            body = await render_markdown(self.app, response.body.decode())\n-            return web.Response(body=body, content_type=\"text/html\")\n-        return await self.request.app.render_template(\n-            template_name, self.request, context\n-        )\n-\n-\n+\tlogin_required=False\n+\tasync def _iter(A):\n+\t\tif A.login_required and(not A.session.get('logged_in')or not A.session.get('uid')):return web.HTTPFound('/')\n+\t\treturn await super()._iter()\n+\t@property\n+\tdef base_url(self):return str(self.request.url.with_path('').with_query(''))\n+\t@property\n+\tdef app(self):return self.request.app\n+\t@property\n+\tdef db(self):return self.app.db\n+\t@property\n+\tdef services(self):return self.app.services\n+\tasync def json_response(B,data,**A):return web.json_response(data,**A)\n+\t@property\n+\tdef session(self):return self.request.session\n+\tasync def render_template(A,template_name,context=None):\n+\t\tC=context;B=template_name\n+\t\tif B.endswith('.md'):D=await A.request.app.render_template(B,A.request,C);E=await render_markdown(A.app,D.body.decode());return web.Response(body=E,content_type='text/html')\n+\t\treturn await A.request.app.render_template(B,A.request,C)\n class BaseFormView(BaseView):\n-\n-    form = None\n-\n-    async def get(self):\n-        form = self.form(app=self.app)\n-\n-        return await self.json_response(await form.to_json())\n-\n-    async def post(self):\n-        form = self.form(app=self.app)\n-        post = await self.request.json()\n-        form.set_user_data(post[\"form\"])\n-        result = await form.to_json()\n-        if post.get(\"action\") == \"validate\":\n-            pass\n-        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n-            result = await self.submit(form)\n-            return await self.json_response(result)\n-        return await self.json_response(result)\n-\n-    async def submit(self, model=None):\n-        pass\n+\tform=None\n+\tasync def get(A):B=A.form(app=A.app);return await A.json_response(await B.to_json())\n+\tasync def post(A):\n+\t\tE='action';C=A.form(app=A.app);D=await A.request.json();C.set_user_data(D['form']);B=await C.to_json()\n+\t\tif D.get(E)=='validate':0\n+\t\tif D.get(E)=='submit'and B['is_valid']:B=await A.submit(C);return await A.json_response(B)\n+\t\treturn await A.json_response(B)\n+\tasync def submit(A,model=None):0\n\\ No newline at end of file\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex aba57ae..740ec7a 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,39 +1,5 @@\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class AboutHTMLView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"about.html\")\n-\n-\n+\tasync def get(A):return await A.render_template('about.html')\n class AboutMDView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"about.md\")\n+\tasync def get(A):return await A.render_template('about.md')\n\\ No newline at end of file\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex a85b876..c95384a 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -1,44 +1,10 @@\n-\n-\n-\n import uuid\n-\n from aiohttp import web\n from multiavatar import multiavatar\n-\n from snek.system.view import BaseView\n-\n-\n class AvatarView(BaseView):\n-    login_required = False\n-\n-    async def get(self):\n-        uid = self.request.match_info.get(\"uid\")\n-        if uid == \"unique\":\n-            uid = str(uuid.uuid4())\n-        avatar = multiavatar.multiavatar(uid, True, None)\n-        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n-        return response\n+\tlogin_required=False\n+\tasync def get(C):\n+\t\tA=C.request.match_info.get('uid')\n+\t\tif A=='unique':A=str(uuid.uuid4())\n+\t\tD=multiavatar.multiavatar(A,True,None);B=web.Response(text=D,content_type='image/svg+xml');B.headers['Cache-Control']=f\"public, max-age={56154}\";return B\n\\ No newline at end of file\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex bb63413..ce1e31c 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,37 +1,5 @@\n-\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class DocsHTMLView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"docs.html\")\n-\n-\n+\tasync def get(A):return await A.render_template('docs.html')\n class DocsMDView(BaseView):\n-\n-    async def get(self):\n-        return await self.render_template(\"docs.md\")\n+\tasync def get(A):return await A.render_template('docs.md')\n\\ No newline at end of file\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex e3c3343..630cc3a 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,269 +1,99 @@\n+_P='Path not found'\n+_O='application/octet-stream'\n+_N='items'\n+_M='size'\n+_L='mimetype'\n+_K='name'\n+_J='rel_path'\n+_I='dir'\n+_H='url'\n+_G=None\n+_F='path'\n+_E='status'\n+_D='file'\n+_C='uid'\n+_B='absolute_url'\n+_A='type'\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n-import os\n-import mimetypes\n+import os,mimetypes\n from aiohttp import web\n-from urllib.parse import unquote, quote\n+from urllib.parse import unquote,quote\n from datetime import datetime\n-\n-\n-\n-\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n-\"\"\"\n from aiohttp import web\n from pathlib import Path\n-import mimetypes, urllib.parse\n-\n-BASE_DIR   = Path(__file__).parent.resolve()\n+import mimetypes,urllib.parse\n+BASE_DIR=Path(__file__).parent.resolve()\n+ROOT_DIR=(BASE_DIR/'storage').resolve()\n+ASSETS_DIR=(BASE_DIR/'assets').resolve()\n ROOT_DIR.mkdir(exist_ok=True)\n ASSETS_DIR.mkdir(exist_ok=True)\n-\n-\n-def safe_resolve_path(rel: str) -> Path:\n-    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n-    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n-    if target == ROOT_DIR or ROOT_DIR in target.parents:\n-        return target\n-    raise FileNotFoundError(\"Unsafe path\")\n-\n-\n+def safe_resolve_path(rel):\n+\t'Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.';A=(ROOT_DIR/rel.lstrip('/')).resolve()\n+\tif A==ROOT_DIR or ROOT_DIR in A.parents:return A\n+\traise FileNotFoundError('Unsafe path')\n class DriveView(BaseView):\n-    async def get(self):\n-        rel = self.request.query.get(\"path\", \"\")\n-        offset = int(self.request.query.get(\"offset\", 0))\n-        limit  = int(self.request.query.get(\"limit\", 20))\n-        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n-        if rel:\n-            target.joinpath(rel)\n-\n-        if not target.exists():\n-            return web.json_response({\"error\": \"Not found\"}, status=404)\n-\n-        if target.is_dir():\n-            entries = []\n-            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n-                item_path = (Path(rel) / p.name).as_posix()\n-                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n-                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n-                        if p.is_file() else None)\n-                entries.append({\n-                    \"name\": p.name,\n-                    \"type\": \"directory\" if p.is_dir() else \"file\",\n-                    \"mimetype\": mime,\n-                    \"size\": p.stat().st_size if p.is_file() else None,\n-                    \"path\": item_path,\n-                    \"url\": url,\n-                })\n-            import json \n-            total = len(entries)\n-            items = entries[offset:offset+limit]\n-            return web.json_response({\n-                \"items\": json.loads(json.dumps(items,default=str)),\n-                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n-            })\n-        \n-        with open(target, \"rb\") as f:\n-            content = f.read()\n-            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n-        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n-        return web.json_response({\n-            \"name\": target.name,\n-            \"type\": \"file\",\n-            \"mimetype\": mimetypes.guess_type(target.name)[0],\n-            \"size\": target.stat().st_size,\n-            \"path\": rel,\n-            \"url\": str(url),\n-        })\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n-\n+\tasync def get(C):\n+\t\tH='limit';I='offset';D=C.request.query.get(_F,'');E=int(C.request.query.get(I,0));J=int(C.request.query.get(H,20));A=await C.services.user.get_home_folder(C.session.get(_C))\n+\t\tif D:A.joinpath(D)\n+\t\tif not A.exists():return web.json_response({'error':'Not found'},status=404)\n+\t\tif A.is_dir():\n+\t\t\tF=[]\n+\t\t\tfor B in sorted(A.iterdir(),key=lambda p:(p.is_file(),p.name.lower())):K=(Path(D)/B.name).as_posix();M=mimetypes.guess_type(B.name)[0]if B.is_file()else'inode/directory';G=C.request.url.with_path(f\"/drive/{urllib.parse.quote(K)}\")if B.is_file()else _G;F.append({_K:B.name,_A:'directory'if B.is_dir()else _D,_L:M,_M:B.stat().st_size if B.is_file()else _G,_F:K,_H:G})\n+\t\t\timport json as L;N=len(F);O=F[E:E+J];return web.json_response({_N:L.loads(L.dumps(O,default=str)),'pagination':{I:E,H:J,'total':N}})\n+\t\twith open(A,'rb')as P:Q=P.read();return web.Response(body=Q,content_type=mimetypes.guess_type(A.name)[0])\n+\t\tG=C.request.url.with_path(f\"/drive/{urllib.parse.quote(D)}\");return web.json_response({_K:A.name,_A:_D,_L:mimetypes.guess_type(A.name)[0],_M:A.stat().st_size,_F:D,_H:str(G)})\n class DriveView222(BaseView):\n-    PAGE_SIZE = 20\n-\n-    async def base_path(self):\n-        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n-\n-    async def get_full_path(self, rel_path):\n-        base_path = await self.base_path()\n-        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n-        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n-        if not full_path.startswith(os.path.abspath(base_path)):\n-            raise web.HTTPForbidden(reason=\"Invalid path\")\n-        return full_path\n-\n-    async def make_absolute_url(self, rel_path):\n-        rel_path = rel_path.lstrip(\"/\")\n-        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n-        return url\n-\n-    async def entry_details(self, dir_path, entry, parent_rel_path):\n-        entry_path = os.path.join(dir_path, entry)\n-        stat = os.stat(entry_path)\n-        is_dir = os.path.isdir(entry_path)\n-        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n-        size = stat.st_size if not is_dir else None\n-        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n-        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n-        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n-        return {\n-            \"name\": entry,\n-            \"type\": \"dir\" if is_dir else \"file\",\n-            \"mimetype\": mimetype,\n-            \"size\": size,\n-            \"created_at\": created_at,\n-            \"updated_at\": updated_at,\n-            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n-        }\n-\n-    async def get(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        page = int(self.request.query.get(\"page\", 1))\n-        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n-        abs_url = await self.make_absolute_url(rel_path)\n-\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"Path not found\")\n-\n-        if os.path.isdir(full_path):\n-            entries = os.listdir(full_path)\n-            entries.sort()\n-            start = (page - 1) * page_size\n-            end = start + page_size\n-            paged_entries = entries[start:end]\n-            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n-            return web.json_response({\n-                \"path\": rel_path,\n-                \"absolute_url\": abs_url,\n-                \"entries\": details,\n-                \"total\": len(entries),\n-                \"page\": page,\n-                \"page_size\": page_size,\n-            })\n-        else:\n-            with open(full_path, \"rb\") as f:\n-                content = f.read()\n-            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n-            headers = {\"X-Absolute-Url\": abs_url}\n-            return web.Response(body=content, content_type=mimetype, headers=headers)\n-\n-    async def post(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if os.path.exists(full_path):\n-            raise web.HTTPConflict(reason=\"File or directory already exists\")\n-        data = await self.request.post()\n-        if data.get(\"type\") == \"dir\":\n-            os.makedirs(full_path)\n-            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n-        else:\n-            file_field = data.get(\"file\")\n-            if not file_field:\n-                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n-            with open(full_path, \"wb\") as f:\n-                f.write(file_field.file.read())\n-            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n-\n-    async def put(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"File not found\")\n-        if os.path.isdir(full_path):\n-            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n-        body = await self.request.read()\n-        with open(full_path, \"wb\") as f:\n-            f.write(body)\n-        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n-\n-    async def delete(self):\n-        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n-        full_path = await self.get_full_path(rel_path)\n-        abs_url = await self.make_absolute_url(rel_path)\n-        if not os.path.exists(full_path):\n-            raise web.HTTPNotFound(reason=\"Path not found\")\n-        if os.path.isdir(full_path):\n-            os.rmdir(full_path)\n-            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n-        else:\n-            os.remove(full_path)\n-            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n-\n-\n+\tPAGE_SIZE=20\n+\tasync def base_path(A):return await A.services.user.get_home_folder(A.session.get(_C))\n+\tasync def get_full_path(C,rel_path):\n+\t\tA=await C.base_path();D=os.path.normpath(unquote(rel_path or''));B=os.path.abspath(os.path.join(A,D))\n+\t\tif not B.startswith(os.path.abspath(A)):raise web.HTTPForbidden(reason='Invalid path')\n+\t\treturn B\n+\tasync def make_absolute_url(B,rel_path):A=rel_path;A=A.lstrip('/');C=str(B.request.url.with_path(f\"/drive/{quote(A)}\"));return C\n+\tasync def entry_details(E,dir_path,entry,parent_rel_path):A=entry;B=os.path.join(dir_path,A);C=os.stat(B);D=os.path.isdir(B);F=_G if D else mimetypes.guess_type(B)[0]or _O;G=C.st_size if not D else _G;H=datetime.fromtimestamp(C.st_ctime).isoformat();I=datetime.fromtimestamp(C.st_mtime).isoformat();J=os.path.join(parent_rel_path,A).replace('\\\\','/');return{_K:A,_A:_I if D else _D,_L:F,_M:G,'created_at':H,'updated_at':I,_B:await E.make_absolute_url(J)}\n+\tasync def get(A):\n+\t\tF='page_size';G='page';C=A.request.match_info.get(_J,'');B=await A.get_full_path(C);H=int(A.request.query.get(G,1));D=int(A.request.query.get(F,A.PAGE_SIZE));I=await A.make_absolute_url(C)\n+\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason=_P)\n+\t\tif os.path.isdir(B):E=os.listdir(B);E.sort();J=(H-1)*D;K=J+D;L=E[J:K];M=[await A.entry_details(B,D,C)for D in L];return web.json_response({_F:C,_B:I,'entries':M,'total':len(E),G:H,F:D})\n+\t\telse:\n+\t\t\twith open(B,'rb')as N:O=N.read()\n+\t\t\tP=mimetypes.guess_type(B)[0]or _O;Q={'X-Absolute-Url':I};return web.Response(body=O,content_type=P,headers=Q)\n+\tasync def post(A):\n+\t\tC='created';D=A.request.match_info.get(_J,'');B=await A.get_full_path(D);E=await A.make_absolute_url(D)\n+\t\tif os.path.exists(B):raise web.HTTPConflict(reason='File or directory already exists')\n+\t\tF=await A.request.post()\n+\t\tif F.get(_A)==_I:os.makedirs(B);return web.json_response({_E:C,_A:_I,_B:E})\n+\t\telse:\n+\t\t\tG=F.get(_D)\n+\t\t\tif not G:raise web.HTTPBadRequest(reason='No file uploaded')\n+\t\t\twith open(B,'wb')as H:H.write(G.file.read())\n+\t\t\treturn web.json_response({_E:C,_A:_D,_B:E})\n+\tasync def put(A):\n+\t\tC=A.request.match_info.get(_J,'');B=await A.get_full_path(C);D=await A.make_absolute_url(C)\n+\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason='File not found')\n+\t\tif os.path.isdir(B):raise web.HTTPBadRequest(reason='Cannot overwrite directory')\n+\t\tE=await A.request.read()\n+\t\twith open(B,'wb')as F:F.write(E)\n+\t\treturn web.json_response({_E:'updated',_B:D})\n+\tasync def delete(B):\n+\t\tC='deleted';D=B.request.match_info.get(_J,'');A=await B.get_full_path(D);E=await B.make_absolute_url(D)\n+\t\tif not os.path.exists(A):raise web.HTTPNotFound(reason=_P)\n+\t\tif os.path.isdir(A):os.rmdir(A);return web.json_response({_E:C,_A:_I,_B:E})\n+\t\telse:os.remove(A);return web.json_response({_E:C,_A:_D,_B:E})\n class DriveViewi2(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-\n-        drive_uid = self.request.match_info.get(\"drive\")\n-            \n-\n-        before = self.request.query.get(\"before\")\n-        filters = {} \n-        if before:\n-            filters[\"created_at__lt\"] = before\n-\n-        if drive_uid:\n-            filters['drive_uid'] = drive_uid \n-            drive = await self.services.drive.get(uid=drive_uid)\n-            drive_items = []\n-            \n-            \n-            \n-            async for item in self.services.drive_item.find(**filters):\n-                record = item.record\n-                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n-                drive_items.append(record)\n-            return web.json_response(drive_items)\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        drives = []\n-        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n-            record = drive.record\n-            record[\"items\"] = []\n-            async for item in drive.items:\n-                drive_item_record = item.record\n-                drive_item_record[\"url\"] = (\n-                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n-                )\n-                record[\"items\"].append(item.record)\n-            drives.append(record)\n-\n-        return web.json_response(drives)\n+\tlogin_required=True\n+\tasync def get(A):\n+\t\tG='/drive.bin/';D=A.request.match_info.get('drive');H=A.request.query.get('before');E={}\n+\t\tif H:E['created_at__lt']=H\n+\t\tif D:\n+\t\t\tE['drive_uid']=D;F=await A.services.drive.get(uid=D);I=[]\n+\t\t\tasync for C in A.services.drive_item.find(**E):B=C.record;B[_H]=G+B[_C]+'.'+C.extension;I.append(B)\n+\t\t\treturn web.json_response(I)\n+\t\tL=await A.services.user.get(uid=A.session.get(_C));J=[]\n+\t\tasync for F in A.services.drive.get_by_user(L[_C]):\n+\t\t\tB=F.record;B[_N]=[]\n+\t\t\tasync for C in F.items:K=C.record;K[_H]=G+K[_C]+'.'+C.extension;B[_N].append(C.record)\n+\t\t\tJ.append(B)\n+\t\treturn web.json_response(J)\n\\ No newline at end of file\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2f44443..2bd3245 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,23 +1,6 @@\n-\n-\n-\n-\n-\n-\n-\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class IndexView(BaseView):\n-    async def get(self):\n-        if self.session.get(\"uid\"):\n-            return web.HTTPFound(\"/web.html\")\n-\n-        return await self.render_template(\"index.html\")\n+\tasync def get(A):\n+\t\tif A.session.get('uid'):return web.HTTPFound('/web.html')\n+\t\treturn await A.render_template('index.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex fe8cf4d..849a8e1 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,44 +1,15 @@\n-\n-\n-\n-\n+_B='/web.html'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n-\n-\n class LoginView(BaseFormView):\n-    form = LoginForm\n-\n-    login_required = False\n-\n-    async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        return await self.render_template(\n-            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n-        )\n-\n-    async def submit(self, form):\n-        if await form.is_valid:\n-            user = await self.services.user.get(\n-                username=form[\"username\"], deleted_at=None\n-            )\n-            await self.services.user.save(user)\n-            self.session.update(\n-                {\n-                    \"logged_in\": True,\n-                    \"username\": user[\"username\"],\n-                    \"uid\": user[\"uid\"],\n-                    \"color\": user[\"color\"],\n-                }\n-            )\n-            return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+\tform=LoginForm;login_required=False\n+\tasync def get(A):\n+\t\tif A.session.get(_A):return web.HTTPFound(_B)\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\treturn await A.render_template('login.html',{'form':await A.form(app=A.app).to_json()})\n+\tasync def submit(B,form):\n+\t\tD='color';E='uid';C='username'\n+\t\tif await form.is_valid:A=await B.services.user.get(username=form[C],deleted_at=None);await B.services.user.save(A);B.session.update({_A:True,C:A[C],E:A[E],D:A[D]});return{'redirect_url':_B}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex acf7c75..39b5be3 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,23 +1,8 @@\n-\n-\n-\n-\n-\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n-\n-\n class LoginFormView(BaseFormView):\n-    form = LoginForm\n-\n-    async def submit(self, form):\n-        if await form.is_valid():\n-            self.session[\"logged_in\"] = True\n-            self.session[\"username\"] = form.username.value\n-            self.session[\"uid\"] = form.uid.value\n-            return {\"redirect_url\": \"/web.html\"}\n-        return {\"is_valid\": False}\n+\tform=LoginForm\n+\tasync def submit(A,form):\n+\t\tB=form\n+\t\tif await B.is_valid():A.session['logged_in']=True;A.session['username']=B.username.value;A.session['uid']=B.uid.value;return{'redirect_url':'/web.html'}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 42016d8..5594774 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,56 +1,14 @@\n-\n-\n-\n-\n-\n-\n-\n-\n+_B='username'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class LogoutView(BaseView):\n-    redirect_url = \"/\"\n-    login_required = True\n-\n-    async def get(self):\n-        try:\n-            del self.session[\"logged_in\"]\n-            del self.session[\"uid\"]\n-            del self.session[\"username\"]\n-        except KeyError:\n-            pass\n-        return web.HTTPFound(self.redirect_url)\n-\n-    async def post(self):\n-        try:\n-            del self.session[\"logged_in\"]\n-            del self.session[\"uid\"]\n-            del self.session[\"username\"]\n-        except KeyError:\n-            pass\n-        return await self.json_response({\"redirect_url\": self.redirect_url})\n+\tredirect_url='/';login_required=True\n+\tasync def get(A):\n+\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n+\t\texcept KeyError:pass\n+\t\treturn web.HTTPFound(A.redirect_url)\n+\tasync def post(A):\n+\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n+\t\texcept KeyError:pass\n+\t\treturn await A.json_response({'redirect_url':A.redirect_url})\n\\ No newline at end of file\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex 96eed8a..ba48820 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,41 +1,12 @@\n-\n-\n-\n-\n+_B='/web.html'\n+_A='logged_in'\n from aiohttp import web\n-\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n-\n-\n class RegisterView(BaseFormView):\n-    form = RegisterForm\n-\n-    login_required = False\n-\n-    async def get(self):\n-        if self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/web.html\")\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        return await self.render_template(\n-            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n-        )\n-\n-    async def submit(self, form):\n-        result = await self.app.services.user.register(\n-            form.email.value, form.username.value, form.password.value\n-        )\n-        self.request.session.update(\n-            {\n-                \"uid\": result[\"uid\"],\n-                \"username\": result[\"username\"],\n-                \"logged_in\": True,\n-                \"color\": result[\"color\"],\n-            }\n-        )\n-        return {\"redirect_url\": \"/web.html\"}\n+\tform=RegisterForm;login_required=False\n+\tasync def get(A):\n+\t\tif A.session.get(_A):return web.HTTPFound(_B)\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\treturn await A.render_template('register.html',{'form':await A.form(app=A.app).to_json()})\n+\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],_A:True,D:B[D]});return{'redirect_url':_B}\n\\ No newline at end of file\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex 7b98647..cf5dbbb 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,47 +1,5 @@\n-\n-\n-\n-\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n-\n-\n class RegisterFormView(BaseFormView):\n-    form = RegisterForm\n-\n-    async def submit(self, form):\n-        result = await self.app.services.user.register(\n-            form.email.value, form.username.value, form.password.value\n-        )\n-        self.request.session.update(\n-            {\n-                \"uid\": result[\"uid\"],\n-                \"username\": result[\"username\"],\n-                \"logged_in\": True,\n-                \"color\": result[\"color\"],\n-            }\n-        )\n-        return {\"redirect_url\": \"/web.html\"}\n+\tform=RegisterForm\n+\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],'logged_in':True,D:B[D]});return{'redirect_url':'/web.html'}\n\\ No newline at end of file\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3161f49..1896ba8 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,283 +1,105 @@\n-\n-\n-\n-\n-\n-import json\n-import traceback\n-\n+_M='noresponse'\n+_L='deleted_at'\n+_K='Not allowed'\n+_J='password'\n+_I='logged_in'\n+_H='channel_uid'\n+_G='last_ping'\n+_F='nick'\n+_E=None\n+_D=True\n+_C=False\n+_B='username'\n+_A='uid'\n+import json,traceback\n from aiohttp import web\n-\n from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n-\n-\n class RPCView(BaseView):\n-\n-    class RPCApi:\n-        def __init__(self, view, ws):\n-            self.view = view\n-            self.app = self.view.app\n-            self.services = self.app.services\n-            self.ws = ws\n-\n-        @property\n-        def user_uid(self):\n-            return self.view.session.get(\"uid\")\n-\n-        @property\n-        def request(self):\n-            return self.view.request\n-\n-        def _require_login(self):\n-            if not self.is_logged_in:\n-                raise Exception(\"Not logged in\")\n-\n-        @property\n-        def is_logged_in(self):\n-            return self.view.session.get(\"logged_in\", False)\n-\n-        async def mark_as_read(self, channel_uid):\n-            self._require_login()\n-            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n-            return True\n-\n-        async def login(self, username, password):\n-            success = await self.services.user.validate_login(username, password)\n-            if not success:\n-                raise Exception(\"Invalid username or password\")\n-            user = await self.services.user.get(username=username)\n-            self.view.session[\"uid\"] = user[\"uid\"]\n-            self.view.session[\"logged_in\"] = True\n-            self.view.session[\"username\"] = user[\"username\"]\n-            self.view.session[\"user_nick\"] = user[\"nick\"]\n-            record = user.record\n-            del record[\"password\"]\n-            del record[\"deleted_at\"]\n-            await self.services.socket.add(\n-                self.ws, self.view.request.session.get(\"uid\")\n-            )\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.view.request.session.get(\"uid\"),\n-                deleted_at=None,\n-                is_banned=False,\n-            ):\n-                await self.services.socket.subscribe(\n-                    self.ws,\n-                    subscription[\"channel_uid\"],\n-                    self.view.request.session.get(\"uid\"),\n-                )\n-            return record\n-\n-        async def search_user(self, query):\n-            self._require_login()\n-            return [user[\"username\"] for user in await self.services.user.search(query)]\n-\n-        async def get_user(self, user_uid):\n-            self._require_login()\n-            if not user_uid:\n-                user_uid = self.user_uid\n-            user = await self.services.user.get(uid=user_uid)\n-            record = user.record\n-            del record[\"password\"]\n-            del record[\"deleted_at\"]\n-            if user_uid != user[\"uid\"]:\n-                del record[\"email\"]\n-            return record\n-\n-        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n-            self._require_login()\n-            messages = []\n-            for message in await self.services.channel_message.offset(\n-                channel_uid, offset or 0, timestamp or None\n-            ):\n-                extended_dict = await self.services.channel_message.to_extended_dict(\n-                    message\n-                )\n-                messages.append(extended_dict)\n-            return messages\n-\n-        async def get_channels(self):\n-            self._require_login()\n-            channels = []\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.user_uid, is_banned=False\n-            ):\n-                channel = await self.services.channel.get(\n-                    uid=subscription[\"channel_uid\"]\n-                )\n-                last_message = await channel.get_last_message()\n-                color = None\n-                if last_message:\n-                    last_message_user = await last_message.get_user()\n-                    color = last_message_user[\"color\"]\n-                channels.append(\n-                    {\n-                        \"name\": subscription[\"label\"],\n-                        \"uid\": subscription[\"channel_uid\"],\n-                        \"tag\": channel[\"tag\"],\n-                        \"new_count\": subscription[\"new_count\"],\n-                        \"is_moderator\": subscription[\"is_moderator\"],\n-                        \"is_read_only\": subscription[\"is_read_only\"],\n-                        \"new_count\": subscription[\"new_count\"],\n-                        \"color\": color,\n-                    }\n-                )\n-            return channels\n-\n-        async def send_message(self, channel_uid, message):\n-            self._require_login()\n-            await self.services.chat.send(self.user_uid, channel_uid, message)\n-            return True\n-\n-        async def echo(self, *args):\n-            self._require_login()\n-            return args\n-\n-        async def query(self, *args):\n-            self._require_login()\n-            query = args[0]\n-            lowercase = query.lower()\n-            if (\n-                any(\n-                    keyword in lowercase\n-                    for keyword in [\n-                        \"drop\",\n-                        \"alter\",\n-                        \"update\",\n-                        \"delete\",\n-                        \"replace\",\n-                        \"insert\",\n-                        \"truncate\",\n-                    ]\n-                )\n-                and \"select\" not in lowercase\n-            ):\n-                raise Exception(\"Not allowed\")\n-            records = [\n-                dict(record) async for record in self.services.channel.query(args[0])\n-            ]\n-            for record in records:\n-                try:\n-                    del record[\"email\"]\n-                except KeyError:\n-                    pass\n-                try:\n-                    del record[\"password\"]\n-                except KeyError:\n-                    pass\n-                try:\n-                    del record[\"message\"]\n-                except:\n-                    pass\n-                try:\n-                    del record[\"html\"]\n-                except:\n-                    pass\n-            return [\n-                dict(record) async for record in self.services.channel.query(args[0])\n-            ]\n-\n-        async def __call__(self, data):\n-            try:\n-                call_id = data.get(\"callId\")\n-                method_name = data.get(\"method\")\n-                if method_name.startswith(\"_\"):\n-                    raise Exception(\"Not allowed\")\n-                args = data.get(\"args\") or []\n-                if hasattr(super(), method_name) or not hasattr(self, method_name):\n-                    return await self._send_json(\n-                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n-                    )\n-                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n-                if not method:\n-                    raise Exception(\"Method not found\")\n-                success = True\n-                try:\n-                    result = await method(*args)\n-                except Exception as ex:\n-                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n-                    success = False\n-                if result != \"noresponse\":\n-                    await self._send_json(\n-                        {\"callId\": call_id, \"success\": success, \"data\": result}\n-                    )\n-            except Exception as ex:\n-                print(str(ex), flush=True)\n-                await self._send_json(\n-                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n-                )\n-\n-        async def _send_json(self, obj):\n-            await self.ws.send_str(json.dumps(obj, default=str))\n-\n-        async def get_online_users(self, channel_uid):\n-            self._require_login()\n-\n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_online_users(channel_uid)\n-            ]\n-\n-        async def echo(self, obj):\n-            await self.ws.send_json(obj)\n-            return \"noresponse\"\n-\n-        async def get_users(self, channel_uid):\n-            self._require_login()\n-\n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_users(channel_uid)\n-            ]\n-\n-        async def ping(self, callId, *args):\n-            if self.user_uid:\n-                user = await self.services.user.get(uid=self.user_uid)\n-                user[\"last_ping\"] = now()\n-                await self.services.user.save(user)\n-            return {\"pong\": args}\n-\n-    async def get(self):\n-        ws = web.WebSocketResponse()\n-        await ws.prepare(self.request)\n-        if self.request.session.get(\"logged_in\"):\n-            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n-            async for subscription in self.services.channel_member.find(\n-                user_uid=self.request.session.get(\"uid\"),\n-                deleted_at=None,\n-                is_banned=False,\n-            ):\n-                await self.services.socket.subscribe(\n-                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n-                )\n-        rpc = RPCView.RPCApi(self, ws)\n-        async for msg in ws:\n-            if msg.type == web.WSMsgType.TEXT:\n-                try:\n-                    async with Profiler():\n-                        await rpc(msg.json())\n-                except Exception as ex:\n-                    print(\"Deleting socket\", ex, flush=True)\n-                    await self.services.socket.delete(ws)\n-                    break\n-            elif msg.type == web.WSMsgType.ERROR:\n-                pass\n-            elif msg.type == web.WSMsgType.CLOSE:\n-                pass\n-        return ws\n+\tclass RPCApi:\n+\t\tdef __init__(A,view,ws):A.view=view;A.app=A.view.app;A.services=A.app.services;A.ws=ws\n+\t\t@property\n+\t\tdef user_uid(self):return self.view.session.get(_A)\n+\t\t@property\n+\t\tdef request(self):return self.view.request\n+\t\tdef _require_login(A):\n+\t\t\tif not A.is_logged_in:raise Exception('Not logged in')\n+\t\t@property\n+\t\tdef is_logged_in(self):return self.view.session.get(_I,_C)\n+\t\tasync def mark_as_read(A,channel_uid):A._require_login();await A.services.channel_member.mark_as_read(channel_uid,A.user_uid);return _D\n+\t\tasync def login(A,username,password):\n+\t\t\tD=username;E=await A.services.user.validate_login(D,password)\n+\t\t\tif not E:raise Exception('Invalid username or password')\n+\t\t\tB=await A.services.user.get(username=D);A.view.session[_A]=B[_A];A.view.session[_I]=_D;A.view.session[_B]=B[_B];A.view.session['user_nick']=B[_F];C=B.record;del C[_J];del C[_L];await A.services.socket.add(A.ws,A.view.request.session.get(_A))\n+\t\t\tasync for F in A.services.channel_member.find(user_uid=A.view.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(A.ws,F[_H],A.view.request.session.get(_A))\n+\t\t\treturn C\n+\t\tasync def search_user(A,query):A._require_login();return[A[_B]for A in await A.services.user.search(query)]\n+\t\tasync def get_user(C,user_uid):\n+\t\t\tA=user_uid;C._require_login()\n+\t\t\tif not A:A=C.user_uid\n+\t\t\tD=await C.services.user.get(uid=A);B=D.record;del B[_J];del B[_L]\n+\t\t\tif A!=D[_A]:del B['email']\n+\t\t\treturn B\n+\t\tasync def get_messages(A,channel_uid,offset=0,timestamp=_E):\n+\t\t\tA._require_login();B=[]\n+\t\t\tfor C in await A.services.channel_message.offset(channel_uid,offset or 0,timestamp or _E):D=await A.services.channel_message.to_extended_dict(C);B.append(D)\n+\t\t\treturn B\n+\t\tasync def get_channels(B):\n+\t\t\tD='is_read_only';E='is_moderator';F='tag';G='color';C='new_count';B._require_login();H=[]\n+\t\t\tasync for A in B.services.channel_member.find(user_uid=B.user_uid,is_banned=_C):\n+\t\t\t\tI=await B.services.channel.get(uid=A[_H]);J=await I.get_last_message();K=_E\n+\t\t\t\tif J:L=await J.get_user();K=L[G]\n+\t\t\t\tH.append({'name':A['label'],_A:A[_H],F:I[F],C:A[C],E:A[E],D:A[D],C:A[C],G:K})\n+\t\t\treturn H\n+\t\tasync def send_message(A,channel_uid,message):A._require_login();await A.services.chat.send(A.user_uid,channel_uid,message);return _D\n+\t\tasync def echo(A,*B):A._require_login();return B\n+\t\tasync def query(B,*C):\n+\t\t\tB._require_login();E=C[0];D=E.lower()\n+\t\t\tif any(A in D for A in['drop','alter','update','delete','replace','insert','truncate'])and'select'not in D:raise Exception(_K)\n+\t\t\tF=[dict(A)async for A in B.services.channel.query(C[0])]\n+\t\t\tfor A in F:\n+\t\t\t\ttry:del A['email']\n+\t\t\t\texcept KeyError:pass\n+\t\t\t\ttry:del A[_J]\n+\t\t\t\texcept KeyError:pass\n+\t\t\t\ttry:del A['message']\n+\t\t\t\texcept:pass\n+\t\t\t\ttry:del A['html']\n+\t\t\t\texcept:pass\n+\t\t\treturn[dict(A)async for A in B.services.channel.query(C[0])]\n+\t\tasync def __call__(A,data):\n+\t\t\tI='success';E='data';F=data;B='callId'\n+\t\t\ttry:\n+\t\t\t\tG=F.get(B);C=F.get('method')\n+\t\t\t\tif C.startswith('_'):raise Exception(_K)\n+\t\t\t\tL=F.get('args')or[]\n+\t\t\t\tif hasattr(super(),C)or not hasattr(A,C):return await A._send_json({B:G,E:_K})\n+\t\t\t\tJ=getattr(A,C.replace('.','_'),_E)\n+\t\t\t\tif not J:raise Exception('Method not found')\n+\t\t\t\tK=_D\n+\t\t\t\ttry:H=await J(*L)\n+\t\t\t\texcept Exception as D:H={'exception':str(D),'traceback':traceback.format_exc()};K=_C\n+\t\t\t\tif H!=_M:await A._send_json({B:G,I:K,E:H})\n+\t\t\texcept Exception as D:print(str(D),flush=_D);await A._send_json({B:G,I:_C,E:str(D)})\n+\t\tasync def _send_json(A,obj):await A.ws.send_str(json.dumps(obj,default=str))\n+\t\tasync def get_online_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_online_users(channel_uid)]\n+\t\tasync def echo(A,obj):await A.ws.send_json(obj);return _M\n+\t\tasync def get_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_users(channel_uid)]\n+\t\tasync def ping(A,callId,*C):\n+\t\t\tif A.user_uid:B=await A.services.user.get(uid=A.user_uid);B[_G]=now();await A.services.user.save(B)\n+\t\t\treturn{'pong':C}\n+\tasync def get(A):\n+\t\tB=web.WebSocketResponse();await B.prepare(A.request)\n+\t\tif A.request.session.get(_I):\n+\t\t\tawait A.services.socket.add(B,A.request.session.get(_A))\n+\t\t\tasync for D in A.services.channel_member.find(user_uid=A.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(B,D[_H],A.request.session.get(_A))\n+\t\tE=RPCView.RPCApi(A,B)\n+\t\tasync for C in B:\n+\t\t\tif C.type==web.WSMsgType.TEXT:\n+\t\t\t\ttry:\n+\t\t\t\t\tasync with Profiler():await E(C.json())\n+\t\t\t\texcept Exception as F:print('Deleting socket',F,flush=_D);await A.services.socket.delete(B);break\n+\t\t\telif C.type==web.WSMsgType.ERROR:0\n+\t\t\telif C.type==web.WSMsgType.CLOSE:0\n+\t\treturn B\n\\ No newline at end of file\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 1f09a26..5e5b7e2 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,56 +1,12 @@\n-\n-\n-\n-\n-\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n-\n-\n class SearchUserView(BaseFormView):\n-    form = SearchUserForm\n-    login_required = True\n-\n-    async def get(self):\n-        users = []\n-        query = self.request.query.get(\"query\")\n-        if query:\n-            users = [user.record for user in await self.app.services.user.search(query)]\n-\n-        if self.request.path.endswith(\".json\"):\n-            return await super().get()\n-        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n-        return await self.render_template(\n-            \"search_user.html\",\n-            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n-        )\n-\n-    async def submit(self, form):\n-        if await form.is_valid:\n-            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n-        return {\"is_valid\": False}\n+\tform=SearchUserForm;login_required=True\n+\tasync def get(A):\n+\t\tC='query';D=[];B=A.request.query.get(C)\n+\t\tif B:D=[A.record for A in await A.app.services.user.search(B)]\n+\t\tif A.request.path.endswith('.json'):return await super().get()\n+\t\tE=await A.app.services.user.get(uid=A.session.get('uid'));return await A.render_template('search_user.html',{'users':D,C:B or'','current_user':E})\n+\tasync def submit(A,form):\n+\t\tif await form.is_valid:return{'redirect_url':'/search-user.html?query='+form['username']}\n+\t\treturn{'is_valid':False}\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex 418ef3d..fc58857 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,9 +1,4 @@\n from snek.system.view import BaseView\n-\n-\n class SettingsIndexView(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        return await self.render_template(\"settings/index.html\")\n+\tlogin_required=True\n+\tasync def get(A):return await A.render_template('settings/index.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 164c526..4a3f897 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,38 +1,13 @@\n+_C='profile'\n+_B='uid'\n+_A='nick'\n from aiohttp import web\n-\n from snek.form.settings.profile import SettingsProfileForm\n from snek.system.view import BaseFormView\n-\n-\n class SettingsProfileView(BaseFormView):\n-    form = SettingsProfileForm\n-\n-    login_required = True\n-\n-    async def get(self):\n-        form = self.form(app=self.app)\n-\n-        if self.request.path.endswith(\".json\"):\n-            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n-\n-            return web.json_response(await form.to_json())\n-\n-        profile = await self.services.user_property.get(\n-            self.session.get(\"uid\"), \"profile\"\n-        )\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        return await self.render_template(\n-            \"settings/profile.html\",\n-            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n-        )\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        user[\"nick\"] = data[\"nick\"]\n-        await self.services.user.save(user)\n-        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n-        return web.HTTPFound(\"/settings/profile.html\")\n+\tform=SettingsProfileForm;login_required=True\n+\tasync def get(A):\n+\t\tC='user';B=A.form(app=A.app)\n+\t\tif A.request.path.endswith('.json'):B[_A]=A.request[C][_A];return web.json_response(await B.to_json())\n+\t\tD=await A.services.user_property.get(A.session.get(_B),_C);E=await A.services.user.get(uid=A.session.get(_B));return await A.render_template('settings/profile.html',{'form':await B.to_json(),C:E,_C:D or''})\n+\tasync def post(A):C=await A.request.post();B=await A.services.user.get(uid=A.session.get(_B));B[_A]=C[_A];await A.services.user.save(B);await A.services.user_property.set(B[_B],_C,C[_C]);return web.HTTPFound('/settings/profile.html')\n\\ No newline at end of file\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 093d229..1cf96fd 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -1,86 +1,37 @@\n+_F='repository'\n+_E='/settings/repositories/index.html'\n+_D='is_private'\n+_C=True\n+_B='name'\n+_A='uid'\n import asyncio\n from aiohttp import web\n-\n from snek.system.view import BaseFormView\n import pathlib\n-\n class RepositoriesIndexView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        \n-        user_uid = self.session.get(\"uid\")\n-        \n-        repositories = []\n-        async for repository in self.services.repository.find(user_uid=user_uid):\n-            repositories.append(repository.record)\n-        \n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-\n-        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})\n-\n-\n-\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tC=A.session.get(_A);B=[]\n+\t\tasync for D in A.services.repository.find(user_uid=C):B.append(D.record)\n+\t\tE=await A.services.user.get(uid=A.session.get(_A));return await A.render_template('settings/repositories/index.html',{'repositories':B,'user':E})\n class RepositoriesCreateView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        \n-        return await self.render_template(\"settings/repositories/create.html\")\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n+\tlogin_required=_C\n+\tasync def get(A):return await A.render_template('settings/repositories/create.html')\n+\tasync def post(A):B=await A.request.post();C=await A.services.repository.create(user_uid=A.session.get(_A),name=B[_B],is_private=int(B.get(_D,0)));return web.HTTPFound(_E)\n class RepositoriesUpdateView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-\n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n-\n-    async def post(self):\n-        data = await self.request.post()\n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        repository['is_private'] = int(data.get('is_private',0))\n-        await self.services.repository.save(repository)\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n+\t\tif not B:return web.HTTPNotFound()\n+\t\treturn await A.render_template('settings/repositories/update.html',{_F:B.record})\n+\tasync def post(A):C=await A.request.post();B=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B]);B[_D]=int(C.get(_D,0));await A.services.repository.save(B);return web.HTTPFound(_E)\n class RepositoriesDeleteView(BaseFormView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-            \n-        repository = await self.services.repository.get(\n-            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-\n-        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n-\n-    async def post(self):\n-        user_uid = self.session.get(\"uid\")\n-        name = self.request.match_info[\"name\"]\n-        repository = await self.services.repository.get(\n-                user_uid=user_uid, name=name\n-        )\n-        if not repository:\n-            return web.HTTPNotFound()\n-        await self.services.repository.delete(user_uid=user_uid, name=name)\n-        return web.HTTPFound(\"/settings/repositories/index.html\")\n-\n-\n+\tlogin_required=_C\n+\tasync def get(A):\n+\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n+\t\tif not B:return web.HTTPNotFound()\n+\t\treturn await A.render_template('settings/repositories/delete.html',{_F:B.record})\n+\tasync def post(A):\n+\t\tB=A.session.get(_A);C=A.request.match_info[_B];D=await A.services.repository.get(user_uid=B,name=C)\n+\t\tif not D:return web.HTTPNotFound()\n+\t\tawait A.services.repository.delete(user_uid=B,name=C);return web.HTTPFound(_E)\n\\ No newline at end of file\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex 1680c5c..dbf7fc6 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,13 +1,5 @@\n import json\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class StatsView(BaseView):\n-\n-    async def get(self):\n-        data = await self.app.cache.get_stats()\n-        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n-        return web.Response(text=data, content_type=\"application/json\")\n+\tasync def get(B):A=await B.app.cache.get_stats();A=json.dumps({'total':len(A),'stats':A},default=str,indent=1);return web.Response(text=A,content_type='application/json')\n\\ No newline at end of file\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 4675572..672d20f 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,73 +1,10 @@\n-\n-\n-\n-\n from snek.system.view import BaseView\n-\n-\n class StatusView(BaseView):\n-    async def get(self):\n-        memberships = []\n-        user = {}\n-\n-        user_id = self.session.get(\"uid\")\n-        if user_id:\n-            user = await self.app.services.user.get(uid=user_id)\n-            if not user:\n-                return await self.json_response({\"error\": \"User not found\"}, status=404)\n-\n-            async for model in self.app.services.channel_member.find(\n-                user_uid=user_id, deleted_at=None, is_banned=False\n-            ):\n-                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n-                memberships.append(\n-                    {\n-                        \"name\": channel[\"label\"],\n-                        \"description\": model[\"description\"],\n-                        \"user_uid\": model[\"user_uid\"],\n-                        \"is_moderator\": model[\"is_moderator\"],\n-                        \"is_read_only\": model[\"is_read_only\"],\n-                        \"is_muted\": model[\"is_muted\"],\n-                        \"is_banned\": model[\"is_banned\"],\n-                        \"channel_uid\": model[\"channel_uid\"],\n-                        \"uid\": model[\"uid\"],\n-                    }\n-                )\n-            user = {\n-                \"username\": user[\"username\"],\n-                \"email\": user[\"email\"],\n-                \"nick\": user[\"nick\"],\n-                \"uid\": user[\"uid\"],\n-                \"color\": user[\"color\"],\n-                \"memberships\": memberships,\n-            }\n-\n-        return await self.json_response(\n-            {\n-                \"user\": user,\n-                \"cache\": await self.app.cache.create_cache_key(\n-                    self.app.cache.cache, None\n-                ),\n-            }\n-        )\n+\tasync def get(C):\n+\t\tG='color';H='nick';I='email';J='username';K='is_banned';L='is_muted';M='is_read_only';N='is_moderator';O='user_uid';P='description';E='channel_uid';D='uid';Q=[];A={};F=C.session.get(D)\n+\t\tif F:\n+\t\t\tA=await C.app.services.user.get(uid=F)\n+\t\t\tif not A:return await C.json_response({'error':'User not found'},status=404)\n+\t\t\tasync for B in C.app.services.channel_member.find(user_uid=F,deleted_at=None,is_banned=False):R=await C.app.services.channel.get(uid=B[E]);Q.append({'name':R['label'],P:B[P],O:B[O],N:B[N],M:B[M],L:B[L],K:B[K],E:B[E],D:B[D]})\n+\t\t\tA={J:A[J],I:A[I],H:A[H],D:A[D],G:A[G],'memberships':Q}\n+\t\treturn await C.json_response({'user':A,'cache':await C.app.cache.create_cache_key(C.app.cache.cache,None)})\n\\ No newline at end of file\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex d3af9b0..43c1fd1 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,54 +1,23 @@\n-import pathlib\n-\n-import aiohttp\n-\n+_B=True\n+_A='uid'\n+import pathlib,aiohttp\n from snek.system.terminal import TerminalSession\n from snek.system.view import BaseView\n-\n-\n class TerminalSocketView(BaseView):\n-\n-    login_required = True\n-\n-    user_sessions = {}\n-\n-    async def prepare_drive(self):\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n-        root.mkdir(parents=True, exist_ok=True)\n-        terminal_folder = pathlib.Path(\"terminal\")\n-        for path in terminal_folder.iterdir():\n-            destination_path = root.joinpath(path.name)\n-            if not path.is_dir():\n-                destination_path.write_bytes(path.read_bytes())\n-        return root\n-\n-    async def get(self):\n-\n-        ws = aiohttp.web.WebSocketResponse()\n-        await ws.prepare(self.request)\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        root = await self.prepare_drive()\n-\n-        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n-\n-        session = self.user_sessions.get(user[\"uid\"])\n-        if not session:\n-            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n-        session = self.user_sessions[user[\"uid\"]]\n-        await session.add_websocket(ws)\n-\n-        async for msg in ws:\n-            if msg.type == aiohttp.WSMsgType.BINARY:\n-                await session.write_input(msg.data.decode())\n-\n-        return ws\n-\n-\n+\tlogin_required=_B;user_sessions={}\n+\tasync def prepare_drive(C):\n+\t\tD=await C.services.user.get(uid=C.session.get(_A));A=pathlib.Path('drive').joinpath(D[_A]);A.mkdir(parents=_B,exist_ok=_B);E=pathlib.Path('terminal')\n+\t\tfor B in E.iterdir():\n+\t\t\tF=A.joinpath(B.name)\n+\t\t\tif not B.is_dir():F.write_bytes(B.read_bytes())\n+\t\treturn A\n+\tasync def get(A):\n+\t\tB=aiohttp.web.WebSocketResponse();await B.prepare(A.request);D=await A.services.user.get(uid=A.session.get(_A));F=await A.prepare_drive();G=f\"docker run -v ./{F}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\";C=A.user_sessions.get(D[_A])\n+\t\tif not C:A.user_sessions[D[_A]]=TerminalSession(command=G)\n+\t\tC=A.user_sessions[D[_A]];await C.add_websocket(B)\n+\t\tasync for E in B:\n+\t\t\tif E.type==aiohttp.WSMsgType.BINARY:await C.write_input(E.data.decode())\n+\t\treturn B\n class TerminalView(BaseView):\n-\n-    login_required = True\n-\n-    async def get(self):\n-        return await self.request.app.render_template(\"terminal.html\", self.request)\n+\tlogin_required=_B\n+\tasync def get(A):return await A.request.app.render_template('terminal.html',A.request)\n\\ No newline at end of file\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex bc923c6..3b7425d 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,37 +1,11 @@\n from snek.system.view import BaseView\n-\n-\n class ThreadsView(BaseView):\n-\n-    async def get(self):\n-        threads = []\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        async for channel_member in user.get_channel_members():\n-            thread = {}\n-            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n-            last_message = await channel.get_last_message()\n-            if not last_message:\n-                continue\n-\n-            thread[\"uid\"] = channel[\"uid\"]\n-            thread[\"name\"] = await channel_member.get_name()\n-            thread[\"new_count\"] = channel_member[\"new_count\"]\n-            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n-            thread[\"created_at\"] = thread[\"last_message_on\"]\n-\n-            thread[\"last_message_text\"] = last_message[\"message\"]\n-            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n-            user_last_message = await self.app.services.user.get(\n-                uid=last_message[\"user_uid\"]\n-            )\n-            if channel[\"tag\"] == \"dm\":\n-                thread[\"name_color\"] = user_last_message[\"color\"]\n-            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n-            threads.append(thread)\n-\n-        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n-\n-        return await self.render_template(\n-            \"threads.html\", {\"threads\": threads, \"user\": user}\n-        )\n+\tasync def get(B):\n+\t\tI='color';J='user_uid';K='name_color';L='new_count';F='uid';C='last_message_on';G=[];M=await B.services.user.get(uid=B.session.get(F))\n+\t\tasync for H in M.get_channel_members():\n+\t\t\tA={};D=await B.services.channel.get(uid=H['channel_uid']);E=await D.get_last_message()\n+\t\t\tif not E:continue\n+\t\t\tif D['tag']=='dm':A[K]=N[I]\n+\t\t\tA['last_message_user_color']=N[I];G.append(A)\n+\t\tG.sort(key=lambda x:x[C]or'',reverse=True);return await B.render_template('threads.html',{'threads':G,'user':M})\n\\ No newline at end of file\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex cf01948..e45d5f6 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,111 +1,19 @@\n-\n-\n-\n-\n-import pathlib\n-import uuid\n-\n-import aiofiles\n+_A='uid'\n+import pathlib,uuid,aiofiles\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class UploadView(BaseView):\n-\n-    async def get(self):\n-        uid = self.request.match_info.get(\"uid\")\n-        drive_item = await self.services.drive_item.get(uid)\n-        response = web.FileResponse(drive_item[\"path\"])\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n-        response.headers[\"Content-Disposition\"] = (\n-            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n-        )\n-        return response\n-\n-    async def post(self):\n-        reader = await self.request.multipart()\n-        files = []\n-\n-        user_uid = self.request.session.get(\"uid\")\n-\n-        upload_dir = await self.services.user.get_home_folder(user_uid)\n-        upload_dir = upload_dir.joinpath(\"upload\")\n-        upload_dir.mkdir(parents=True, exist_ok=True)\n-\n-        channel_uid = None\n-\n-        drive = await self.services.drive.get_or_create(\n-            user_uid=self.request.session.get(\"uid\")\n-        )\n-\n-        extension_types = {\n-            \".jpg\": \"image\",\n-            \".gif\": \"image\",\n-            \".png\": \"image\",\n-            \".jpeg\": \"image\",\n-            \".mp4\": \"video\",\n-            \".mp3\": \"audio\",\n-            \".pdf\": \"document\",\n-            \".doc\": \"document\",\n-            \".docx\": \"document\",\n-        }\n-\n-        while field := await reader.next():\n-            if field.name == \"channel_uid\":\n-                channel_uid = await field.text()\n-                continue\n-\n-            filename = field.filename\n-            if not filename:\n-                continue\n-\n-            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n-\n-            file_path = upload_dir.joinpath(name)\n-            files.append(file_path)\n-\n-            async with aiofiles.open(str(file_path), \"wb\") as f:\n-                while chunk := await field.read_chunk():\n-                    await f.write(chunk)\n-\n-            drive_item = await self.services.drive_item.create(\n-                drive[\"uid\"],\n-                filename,\n-                str(file_path),\n-                file_path.stat().st_size,\n-                file_path.suffix,\n-            )\n-\n-            extension = \".\" + filename.split(\".\")[-1]\n-            if extension in extension_types:\n-                extension_types[extension]\n-\n-            await self.services.drive_item.save(drive_item)\n-            response = (\n-                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n-            )\n-            response = (\n-                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n-            )\n-\n-            await self.services.chat.send(\n-                self.request.session.get(\"uid\"), channel_uid, response\n-            )\n-\n-        return web.json_response(\n-            {\n-                \"message\": \"Files uploaded successfully\",\n-                \"files\": [str(file) for file in files],\n-                \"channel_uid\": channel_uid,\n-            }\n-        )\n+\tasync def get(B):D=B.request.match_info.get(_A);C=await B.services.drive_item.get(D);A=web.FileResponse(C['path']);A.headers['Cache-Control']=f\"public, max-age={561540}\";A.headers['Content-Disposition']=f'attachment; filename=\"{C[\"name\"]}\"';return A\n+\tasync def post(A):\n+\t\tK='](/drive.bin/';L='channel_uid';G='document';D='image';P=await A.request.multipart();M=[];Q=A.request.session.get(_A);E=await A.services.user.get_home_folder(Q);E=E.joinpath('upload');E.mkdir(parents=True,exist_ok=True);H=None;R=await A.services.drive.get_or_create(user_uid=A.request.session.get(_A));N={'.jpg':D,'.gif':D,'.png':D,'.jpeg':D,'.mp4':'video','.mp3':'audio','.pdf':G,'.doc':G,'.docx':G}\n+\t\twhile(F:=await P.next()):\n+\t\t\tif F.name==L:H=await F.text();continue\n+\t\t\tB=F.filename\n+\t\t\tif not B:continue\n+\t\t\tS=str(uuid.uuid4())+pathlib.Path(B).suffix;C=E.joinpath(S);M.append(C)\n+\t\t\tasync with aiofiles.open(str(C),'wb')as T:\n+\t\t\t\twhile(U:=await F.read_chunk()):await T.write(U)\n+\t\t\tI=await A.services.drive_item.create(R[_A],B,str(C),C.stat().st_size,C.suffix);J='.'+B.split('.')[-1]\n+\t\t\tif J in N:N[J]\n+\t\t\tawait A.services.drive_item.save(I);O='Uploaded ['+B+K+I[_A]+')';O='['+B+K+I[_A]+J+')';await A.services.chat.send(A.request.session.get(_A),H,O)\n+\t\treturn web.json_response({'message':'Files uploaded successfully','files':[str(A)for A in M],L:H})\n\\ No newline at end of file\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex 312f7bf..ab00e1a 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -1,15 +1,3 @@\n from snek.system.view import BaseView\n-\n-\n class UserView(BaseView):\n-\n-    async def get(self):\n-        user_uid = self.request.match_info.get(\"user\")\n-        user = await self.services.user.get(uid=user_uid)\n-        profile_content = (\n-            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n-        )\n-        return await self.render_template(\n-            \"user.html\",\n-            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n-        )\n+\tasync def get(A):B='profile';C='user';D=A.request.match_info.get(C);E=await A.services.user.get(uid=D);F=await A.services.user_property.get(E['uid'],B)or'';return await A.render_template('user.html',{'user_uid':D,C:E.record,B:F})\n\\ No newline at end of file\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 111f76c..292586d 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,79 +1,19 @@\n-\n-\n-\n-\n from aiohttp import web\n-\n from snek.system.view import BaseView\n-\n-\n class WebView(BaseView):\n-    login_required = True\n-\n-    async def get(self):\n-        if self.login_required and not self.session.get(\"logged_in\"):\n-            return web.HTTPFound(\"/\")\n-        channel = await self.services.channel.get(\n-            uid=self.request.match_info.get(\"channel\")\n-        )\n-        if not channel:\n-            user = await self.services.user.get(\n-                uid=self.request.match_info.get(\"channel\")\n-            )\n-            if user:\n-                channel = await self.services.channel.get_dm(\n-                    self.session.get(\"uid\"), user[\"uid\"]\n-                )\n-                if channel:\n-                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n-        if not channel:\n-            return web.HTTPNotFound()\n-\n-        channel_member = await self.app.services.channel_member.get(\n-            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n-        )\n-        if not channel_member:\n-            return web.HTTPNotFound()\n-\n-        channel_member[\"new_count\"] = 0\n-        await self.app.services.channel_member.save(channel_member)\n-\n-        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n-        messages = [\n-            await self.app.services.channel_message.to_extended_dict(message)\n-            for message in await self.app.services.channel_message.offset(\n-                channel[\"uid\"]\n-            )\n-        ]\n-        for message in messages:\n-            await self.app.services.notification.mark_as_read(\n-                self.session.get(\"uid\"), message[\"uid\"]\n-            )\n-\n-        name = await channel_member.get_name()\n-        return await self.render_template(\n-            \"web.html\",\n-            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n-        )\n+\tlogin_required=True\n+\tasync def get(A):\n+\t\tF='channel';B='uid'\n+\t\tif A.login_required and not A.session.get('logged_in'):return web.HTTPFound('/')\n+\t\tC=await A.services.channel.get(uid=A.request.match_info.get(F))\n+\t\tif not C:\n+\t\t\tD=await A.services.user.get(uid=A.request.match_info.get(F))\n+\t\t\tif D:\n+\t\t\t\tC=await A.services.channel.get_dm(A.session.get(B),D[B])\n+\t\t\t\tif C:return web.HTTPFound('/channel/{}.html'.format(C[B]))\n+\t\tif not C:return web.HTTPNotFound()\n+\t\tE=await A.app.services.channel_member.get(user_uid=A.session.get(B),channel_uid=C[B])\n+\t\tif not E:return web.HTTPNotFound()\n+\t\tE['new_count']=0;await A.app.services.channel_member.save(E);D=await A.services.user.get(uid=A.session.get(B));G=[await A.app.services.channel_message.to_extended_dict(B)for B in await A.app.services.channel_message.offset(C[B])]\n+\t\tfor H in G:await A.app.services.notification.mark_as_read(A.session.get(B),H[B])\n+\t\tI=await E.get_name();return await A.render_template('web.html',{'name':I,F:C,'user':D,'messages':G})\n\\ No newline at end of file\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 4c57fab..0d0ae16 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,377 +1,145 @@\n-import logging\n-import pathlib\n-\n+_U='Lock-Token'\n+_T='application/xml'\n+_S='{DAV:}exclusive'\n+_R='{DAV:}lockdiscovery'\n+_Q='{DAV:}prop'\n+_P='%a, %d %b %Y %H:%M:%S GMT'\n+_O='Source not found'\n+_M='Destination'\n+_L='application/octet-stream'\n+_K='File not found'\n+_J='{DAV:}write'\n+_I='{DAV:}locktype'\n+_H='{DAV:}lockscope'\n+_G='{DAV:}href'\n+_F='Content-Type'\n+_E=True\n+_D='filename'\n+_C='Basic realm=\"WebDAV\"'\n+_B='WWW-Authenticate'\n+_A='home'\n+import logging,pathlib\n logging.basicConfig(level=logging.DEBUG)\n-import base64\n-import datetime\n-import mimetypes\n-import os\n-import shutil\n-import uuid\n-\n-import aiofiles\n-import aiohttp\n-import aiohttp.web\n+import base64,datetime,mimetypes,os,shutil,uuid,aiofiles,aiohttp,aiohttp.web\n from app.cache import time_cache_async\n from lxml import etree\n-\n-\n @aiohttp.web.middleware\n-async def debug_middleware(request, handler):\n-    print(request.method, request.path, request.headers)\n-    result = await handler(request)\n-    print(result.status)\n-    try:\n-        print(await result.text())\n-    except:\n-        pass\n-    return result\n-\n-\n+async def debug_middleware(request,handler):\n+\tA=request;print(A.method,A.path,A.headers);B=await handler(A);print(B.status)\n+\ttry:print(await B.text())\n+\texcept:pass\n+\treturn B\n class WebdavApplication(aiohttp.web.Application):\n-    def __init__(self, parent, *args, **kwargs):\n-        middlewares = [debug_middleware]\n-\n-        super().__init__(middlewares=middlewares, *args, **kwargs)\n-        self.locks = {}\n-\n-        self.relative_url = \"/webdav\"\n-\n-        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n-        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n-        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n-        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n-        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n-        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n-        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n-        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n-        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n-        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n-        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n-        self.parent = parent\n-\n-    @property\n-    def db(self):\n-        return self.parent.db\n-\n-    @property\n-    def services(self):\n-        return self.parent.services\n-\n-    async def authenticate(self, request):\n-        auth_header = request.headers.get(\"Authorization\", \"\")\n-        if not auth_header.startswith(\"Basic \"):\n-            return False\n-        encoded_creds = auth_header.split(\"Basic \")[1]\n-        decoded_creds = base64.b64decode(encoded_creds).decode()\n-        username, password = decoded_creds.split(\":\", 1)\n-        request[\"user\"] = await self.services.user.authenticate(\n-            username=username, password=password\n-        )\n-        try:\n-            request[\"home\"] = await self.services.user.get_home_folder(\n-                request[\"user\"][\"uid\"]\n-            )\n-        except Exception:\n-            pass\n-        return request[\"user\"]\n-\n-    async def handle_get(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request[\"home\"] / requested_path\n-\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"File not found\")\n-\n-        if abs_path.is_dir():\n-            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n-\n-        content_type, _ = mimetypes.guess_type(str(abs_path))\n-        content_type = content_type or \"application/octet-stream\"\n-\n-        return aiohttp.web.FileResponse(\n-            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n-        )\n-\n-    async def handle_put(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        file_path.parent.mkdir(parents=True, exist_ok=True)\n-        async with aiofiles.open(file_path, \"wb\") as f:\n-            while chunk := await request.content.read(1024):\n-                await f.write(chunk)\n-        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n-\n-    async def handle_delete(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        file_path = request[\"home\"] / request.match_info[\"filename\"]\n-        if file_path.is_file():\n-            file_path.unlink()\n-            return aiohttp.web.Response(status=204)\n-        elif file_path.is_dir():\n-            shutil.rmtree(file_path)\n-            return aiohttp.web.Response(status=204)\n-        return aiohttp.web.Response(status=404, text=\"Not found\")\n-\n-    async def handle_mkcol(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n-        if dir_path.exists():\n-            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n-        dir_path.mkdir(parents=True, exist_ok=True)\n-        return aiohttp.web.Response(status=201, text=\"Directory created\")\n-\n-    async def handle_move(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        src_path = request[\"home\"] / request.match_info[\"filename\"]\n-        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n-        )\n-        if not src_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Source not found\")\n-        shutil.move(str(src_path), str(dest_path))\n-        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n-\n-    async def handle_copy(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        src_path = request[\"home\"] / request.match_info[\"filename\"]\n-        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n-        )\n-        if not src_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Source not found\")\n-        if src_path.is_file():\n-            shutil.copy2(str(src_path), str(dest_path))\n-        else:\n-            shutil.copytree(str(src_path), str(dest_path))\n-        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n-\n-    async def handle_options(self, request):\n-        headers = {\n-            \"DAV\": \"1, 2\",\n-            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n-        }\n-        return aiohttp.web.Response(status=200, headers=headers)\n-\n-    def get_current_utc_time(self, filepath):\n-        if filepath.exists():\n-            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n-        else:\n-            modified_time = datetime.datetime.utcnow()\n-        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n-            \"%a, %d %b %Y %H:%M:%S GMT\"\n-        )\n-\n-    @time_cache_async(10)\n-    async def get_file_size(self, path):\n-        loop = self.parent.loop\n-        stat = await loop.run_in_executor(None, os.stat, path)\n-        return stat.st_size\n-\n-    @time_cache_async(10)\n-    async def get_directory_size(self, directory):\n-        total_size = 0\n-        for dirpath, _, filenames in os.walk(directory):\n-            for f in filenames:\n-                fp = pathlib.Path(dirpath) / f\n-                if fp.exists():\n-                    total_size += await self.get_file_size(str(fp))\n-        return total_size\n-\n-    @time_cache_async(30)\n-    async def get_disk_free_space(self, path=\"/\"):\n-        loop = self.parent.loop\n-        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n-        return statvfs.f_bavail * statvfs.f_frsize\n-\n-    async def create_node(self, request, response_xml, full_path, depth):\n-        abs_path = pathlib.Path(full_path)\n-        relative_path = str(full_path.relative_to(request[\"home\"]))\n-\n-        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n-        href_path = href_path.replace(\"./\", \"/\")\n-\n-        response = etree.SubElement(response_xml, \"{DAV:}response\")\n-        href = etree.SubElement(response, \"{DAV:}href\")\n-        href.text = href_path\n-        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n-        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n-        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n-        if full_path.is_dir():\n-            etree.SubElement(res_type, \"{DAV:}collection\")\n-        creation_date, last_modified = self.get_current_utc_time(full_path)\n-        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n-        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n-            await self.get_file_size(full_path)\n-            if full_path.is_file()\n-            else await self.get_directory_size(full_path)\n-        )\n-        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n-            await self.get_disk_free_space(request[\"home\"])\n-        )\n-        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n-        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n-        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n-        mimetype, _ = mimetypes.guess_type(full_path.name)\n-        if full_path.is_file():\n-            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n-            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n-                await self.get_file_size(full_path)\n-                if full_path.is_file()\n-                else await self.get_directory_size(full_path)\n-            )\n-        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n-        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n-        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type_1, \"{DAV:}write\")\n-        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n-        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n-        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type_2, \"{DAV:}write\")\n-        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n-\n-        if abs_path.is_dir() and depth > 0:\n-            for item in abs_path.iterdir():\n-                await self.create_node(request, response_xml, item, depth - 1)\n-\n-    async def handle_propfind(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        depth = 0\n-        try:\n-            depth = int(request.headers.get(\"Depth\", \"0\"))\n-        except ValueError:\n-            pass\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-\n-        abs_path = request[\"home\"] / requested_path\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n-        nsmap = {\"D\": \"DAV:\"}\n-        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n-\n-        await self.create_node(request, response_xml, abs_path, depth)\n-\n-        xml_output = etree.tostring(\n-            response_xml, encoding=\"utf-8\", xml_declaration=True\n-        ).decode()\n-        return aiohttp.web.Response(\n-            status=207, text=xml_output, content_type=\"application/xml\"\n-        )\n-\n-    async def handle_proppatch(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n-\n-    async def handle_lock(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        resource = request.match_info.get(\"filename\", \"/\")\n-        lock_id = str(uuid.uuid4())\n-        self.locks[resource] = lock_id\n-        xml_response = await self.generate_lock_response(lock_id)\n-        headers = {\n-            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n-            \"Content-Type\": \"application/xml\",\n-        }\n-        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n-\n-    async def handle_unlock(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-        resource = request.match_info.get(\"filename\", \"/\")\n-        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n-            \"opaquelocktoken:\", \"\"\n-        )[1:-1]\n-        if self.locks.get(resource) == lock_token:\n-            del self.locks[resource]\n-            return aiohttp.web.Response(status=204)\n-        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n-\n-    async def generate_lock_response(self, lock_id):\n-        nsmap = {\"D\": \"DAV:\"}\n-        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n-        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n-        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n-        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n-        etree.SubElement(lock_type, \"{DAV:}write\")\n-        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n-        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n-        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n-        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n-        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n-        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n-        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n-        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n-        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n-\n-    def get_last_modified(self, path):\n-        if not path.exists():\n-            return None\n-        timestamp = path.stat().st_mtime\n-        dt = datetime.datetime.utcfromtimestamp(timestamp)\n-        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n-\n-    async def handle_head(self, request):\n-        if not await self.authenticate(request):\n-            return aiohttp.web.Response(\n-                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n-            )\n-\n-        requested_path = request.match_info.get(\"filename\", \"\")\n-        abs_path = request[\"home\"] / requested_path\n-\n-        if not abs_path.exists():\n-            return aiohttp.web.Response(status=404, text=\"File not found\")\n-\n-        if abs_path.is_dir():\n-            return aiohttp.web.Response(\n-                status=403, text=\"Cannot get metadata for a directory\"\n-            )\n-\n-        content_type, _ = mimetypes.guess_type(str(abs_path))\n-        content_type = content_type or \"application/octet-stream\"\n-        file_size = abs_path.stat().st_size\n-\n-        headers = {\n-            \"Content-Type\": content_type,\n-            \"Content-Length\": str(file_size),\n-            \"Last-Modified\": self.get_last_modified(abs_path),\n-        }\n-\n-        return aiohttp.web.Response(status=200, headers=headers)\n+\tdef __init__(A,parent,*C,**D):B='/{filename:.*}';E=[debug_middleware];super().__init__(*C,middlewares=E,**D);A.locks={};A.relative_url='/webdav';A.router.add_route('OPTIONS',B,A.handle_options);A.router.add_route('GET',B,A.handle_get);A.router.add_route('PUT',B,A.handle_put);A.router.add_route('DELETE',B,A.handle_delete);A.router.add_route('MKCOL',B,A.handle_mkcol);A.router.add_route('MOVE',B,A.handle_move);A.router.add_route('COPY',B,A.handle_copy);A.router.add_route('PROPFIND',B,A.handle_propfind);A.router.add_route('PROPPATCH',B,A.handle_proppatch);A.router.add_route('LOCK',B,A.handle_lock);A.router.add_route('UNLOCK',B,A.handle_unlock);A.parent=parent\n+\t@property\n+\tdef db(self):return self.parent.db\n+\t@property\n+\tdef services(self):return self.parent.services\n+\tasync def authenticate(C,request):\n+\t\tD='Basic ';B='user';A=request;E=A.headers.get('Authorization','')\n+\t\tif not E.startswith(D):return False\n+\t\tF=E.split(D)[1];G=base64.b64decode(F).decode();H,I=G.split(':',1);A[B]=await C.services.user.authenticate(username=H,password=I)\n+\t\ttry:A[_A]=await C.services.user.get_home_folder(A[B]['uid'])\n+\t\texcept Exception:pass\n+\t\treturn A[B]\n+\tasync def handle_get(D,request):\n+\t\tB=request\n+\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n+\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n+\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot download a directory')\n+\t\tC,F=mimetypes.guess_type(str(A));C=C or _L;return aiohttp.web.FileResponse(path=str(A),headers={_F:C},chunk_size=8192)\n+\tasync def handle_put(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];B.parent.mkdir(parents=_E,exist_ok=_E)\n+\t\tasync with aiofiles.open(B,'wb')as D:\n+\t\t\twhile(E:=await A.content.read(1024)):await D.write(E)\n+\t\treturn aiohttp.web.Response(status=201,text='File uploaded')\n+\tasync def handle_delete(C,request):\n+\t\tB=request\n+\t\tif not await C.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tA=B[_A]/B.match_info[_D]\n+\t\tif A.is_file():A.unlink();return aiohttp.web.Response(status=204)\n+\t\telif A.is_dir():shutil.rmtree(A);return aiohttp.web.Response(status=204)\n+\t\treturn aiohttp.web.Response(status=404,text='Not found')\n+\tasync def handle_mkcol(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D]\n+\t\tif B.exists():return aiohttp.web.Response(status=405,text='Directory already exists')\n+\t\tB.mkdir(parents=_E,exist_ok=_E);return aiohttp.web.Response(status=201,text='Directory created')\n+\tasync def handle_move(C,request):\n+\t\tA=request\n+\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];D=A[_A]/A.headers.get(_M,'').replace(_N,'')\n+\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n+\t\tshutil.move(str(B),str(D));return aiohttp.web.Response(status=201,text='Moved successfully')\n+\tasync def handle_copy(D,request):\n+\t\tA=request\n+\t\tif not await D.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tB=A[_A]/A.match_info[_D];C=A[_A]/A.headers.get(_M,'').replace(_N,'')\n+\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n+\t\tif B.is_file():shutil.copy2(str(B),str(C))\n+\t\telse:shutil.copytree(str(B),str(C))\n+\t\treturn aiohttp.web.Response(status=201,text='Copied successfully')\n+\tasync def handle_options(B,request):A={'DAV':'1, 2','Allow':'OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH'};return aiohttp.web.Response(status=200,headers=A)\n+\tdef get_current_utc_time(C,filepath):\n+\t\tB=filepath\n+\t\tif B.exists():A=datetime.datetime.utcfromtimestamp(B.stat().st_mtime)\n+\t\telse:A=datetime.datetime.utcnow()\n+\t\treturn A.strftime('%Y-%m-%dT%H:%M:%SZ'),A.strftime(_P)\n+\t@time_cache_async(10)\n+\tasync def get_file_size(self,path):A=self.parent.loop;B=await A.run_in_executor(None,os.stat,path);return B.st_size\n+\t@time_cache_async(10)\n+\tasync def get_directory_size(self,directory):\n+\t\tA=0\n+\t\tfor(C,F,D)in os.walk(directory):\n+\t\t\tfor E in D:\n+\t\t\t\tB=pathlib.Path(C)/E\n+\t\t\t\tif B.exists():A+=await self.get_file_size(str(B))\n+\t\treturn A\n+\t@time_cache_async(30)\n+\tasync def get_disk_free_space(self,path='/'):B=self.parent.loop;A=await B.run_in_executor(None,os.statvfs,path);return A.f_bavail*A.f_frsize\n+\tasync def create_node(C,request,response_xml,full_path,depth):\n+\t\tif A.is_dir():etree.SubElement(Q,'{DAV:}collection')\n+\t\tR,S=C.get_current_utc_time(A);etree.SubElement(B,'{DAV:}creationdate').text=R;etree.SubElement(B,'{DAV:}quota-used-bytes').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A));etree.SubElement(B,'{DAV:}quota-available-bytes').text=str(await C.get_disk_free_space(E[_A]));etree.SubElement(B,'{DAV:}getlastmodified').text=S;etree.SubElement(B,'{DAV:}displayname').text=A.name;etree.SubElement(B,_R);T,Z=mimetypes.guess_type(A.name)\n+\t\tif A.is_file():etree.SubElement(B,'{DAV:}contenttype').text=T;etree.SubElement(B,'{DAV:}getcontentlength').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A))\n+\t\tL=etree.SubElement(B,'{DAV:}supportedlock');M=etree.SubElement(L,F);U=etree.SubElement(M,_H);etree.SubElement(U,_S);V=etree.SubElement(M,_I);etree.SubElement(V,_J);N=etree.SubElement(L,F);W=etree.SubElement(N,_H);etree.SubElement(W,'{DAV:}shared');X=etree.SubElement(N,_I);etree.SubElement(X,_J);etree.SubElement(K,'{DAV:}status').text='HTTP/1.1 200 OK'\n+\t\tif I.is_dir()and G>0:\n+\t\t\tfor Y in I.iterdir():await C.create_node(E,H,Y,G-1)\n+\tasync def handle_propfind(B,request):\n+\t\tA=request\n+\t\tif not await B.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tC=0\n+\t\ttry:C=int(A.headers.get('Depth','0'))\n+\t\texcept ValueError:pass\n+\t\tF=A.match_info.get(_D,'');D=A[_A]/F\n+\t\tif not D.exists():return aiohttp.web.Response(status=404,text='Directory not found')\n+\t\tG={'D':'DAV:'};E=etree.Element('{DAV:}multistatus',nsmap=G);await B.create_node(A,E,D,C);H=etree.tostring(E,encoding='utf-8',xml_declaration=_E).decode();return aiohttp.web.Response(status=207,text=H,content_type=_T)\n+\tasync def handle_proppatch(A,request):\n+\t\tif not await A.authenticate(request):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\treturn aiohttp.web.Response(status=207,text='PROPPATCH OK (Not Implemented)')\n+\tasync def handle_lock(A,request):\n+\t\tC=request\n+\t\tif not await A.authenticate(C):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tD=C.match_info.get(_D,'/');B=str(uuid.uuid4());A.locks[D]=B;E=await A.generate_lock_response(B);F={_U:f\"opaquelocktoken:{B}\",_F:_T};return aiohttp.web.Response(text=E,headers=F,status=200)\n+\tasync def handle_unlock(A,request):\n+\t\tB=request\n+\t\tif not await A.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tC=B.match_info.get(_D,'/');D=B.headers.get(_U,'').replace('opaquelocktoken:','')[1:-1]\n+\t\tif A.locks.get(C)==D:del A.locks[C];return aiohttp.web.Response(status=204)\n+\t\treturn aiohttp.web.Response(status=400,text='Invalid Lock Token')\n+\tasync def generate_lock_response(J,lock_id):B=lock_id;D={'D':'DAV:'};C=etree.Element(_Q,nsmap=D);E=etree.SubElement(C,_R);A=etree.SubElement(E,'{DAV:}activelock');F=etree.SubElement(A,_I);etree.SubElement(F,_J);G=etree.SubElement(A,_H);etree.SubElement(G,_S);etree.SubElement(A,'{DAV:}depth').text='Infinity';H=etree.SubElement(A,'{DAV:}owner');etree.SubElement(H,_G).text=B;etree.SubElement(A,'{DAV:}timeout').text='Infinite';I=etree.SubElement(A,'{DAV:}locktoken');etree.SubElement(I,_G).text=f\"opaquelocktoken:{B}\";return etree.tostring(C,pretty_print=_E,encoding='utf-8').decode()\n+\tdef get_last_modified(C,path):\n+\t\tif not path.exists():return\n+\t\tA=path.stat().st_mtime;B=datetime.datetime.utcfromtimestamp(A);return B.strftime(_P)\n+\tasync def handle_head(D,request):\n+\t\tB=request\n+\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n+\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n+\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n+\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot get metadata for a directory')\n+\t\tC,H=mimetypes.guess_type(str(A));C=C or _L;F=A.stat().st_size;G={_F:C,'Content-Length':str(F),'Last-Modified':D.get_last_modified(A)};return aiohttp.web.Response(status=200,headers=G)\n\\ No newline at end of file\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex aab17e4..a2087be 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,78 +1,25 @@\n-import asyncio\n-import logging\n-import os\n-\n-import asyncssh\n-\n+_A=True\n+import asyncio,logging,os,asyncssh\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n-USERNAME = \"test\"\n-PASSWORD = \"woeii\"\n-HOST = \"localhost\"\n-PORT = 2225\n-\n-\n+SFTP_ROOT='.'\n+USERNAME='test'\n+PASSWORD='woeii'\n+HOST='localhost'\n+PORT=2225\n class MySFTPServer(asyncssh.SFTPServer):\n-    def __init__(self, chan):\n-        super().__init__(chan)\n-        self.root = os.path.abspath(SFTP_ROOT)\n-\n-    async def stat(self, path):\n-        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().stat(full_path)\n-\n-    async def open(self, path, flags, attrs):\n-        \"\"\"Handles file open requests\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().open(full_path, flags, attrs)\n-\n-    async def listdir(self, path):\n-        \"\"\"Handles directory listing\"\"\"\n-        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n-        return await super().listdir(full_path)\n-\n-\n+\tdef __init__(A,chan):super().__init__(chan);A.root=os.path.abspath(SFTP_ROOT)\n+\tasync def stat(A,path):\"Handles 'stat' command from SFTP client\";B=os.path.join(A.root,path.lstrip('/'));return await super().stat(B)\n+\tasync def open(A,path,flags,attrs):'Handles file open requests';B=os.path.join(A.root,path.lstrip('/'));return await super().open(B,flags,attrs)\n+\tasync def listdir(A,path):'Handles directory listing';B=os.path.join(A.root,path.lstrip('/'));return await super().listdir(B)\n class MySSHServer(asyncssh.SSHServer):\n-    \"\"\"Custom SSH server to handle authentication\"\"\"\n-\n-    def connection_made(self, conn):\n-        print(f\"New connection from {conn.get_extra_info('peername')}\")\n-\n-    def connection_lost(self, exc):\n-        print(\"Client disconnected\")\n-\n-    def begin_auth(self, username):\n-\n-    def password_auth_supported(self):\n-\n-    def validate_password(self, username, password):\n-        print(username, password)\n-\n-        return True\n-        return username == USERNAME and password == PASSWORD\n-\n-\n-async def start_sftp_server():\n-\n-    await asyncssh.create_server(\n-        lambda: MySSHServer(),\n-        host=HOST,\n-        port=PORT,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=MySFTPServer,\n-    )\n-    print(f\"SFTP server running on {HOST}:{PORT}\")\n-\n-\n-if __name__ == \"__main__\":\n-    try:\n-        asyncio.run(start_sftp_server())\n-    except (OSError, asyncssh.Error) as e:\n-        print(f\"Error starting SFTP server: {e}\")\n+\t'Custom SSH server to handle authentication'\n+\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n+\tdef connection_lost(A,exc):print('Client disconnected')\n+\tdef begin_auth(A,username):return _A\n+\tdef password_auth_supported(A):return _A\n+\tdef validate_password(C,username,password):A=password;B=username;print(B,A);return _A;return B==USERNAME and A==PASSWORD\n+async def start_sftp_server():os.makedirs(SFTP_ROOT,exist_ok=_A);await asyncssh.create_server(lambda:MySSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=MySFTPServer);print(f\"SFTP server running on {HOST}:{PORT}\");await asyncio.Future()\n+if __name__=='__main__':\n+\ttry:asyncio.run(start_sftp_server())\n+\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SFTP server: {e}\")\n\\ No newline at end of file\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 2fa26a7..8879a05 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,77 +1,28 @@\n-import asyncio\n-import os\n-\n-import asyncssh\n-\n-HOST = \"0.0.0.0\"\n-PORT = 2225\n-USERNAME = \"user\"\n-PASSWORD = \"password\"\n-\n-\n+import asyncio,os,asyncssh\n+HOST='0.0.0.0'\n+PORT=2225\n+USERNAME='user'\n+PASSWORD='password'\n+SHELL='/bin/sh'\n class CustomSSHServer(asyncssh.SSHServer):\n-    def connection_made(self, conn):\n-        print(f\"New connection from {conn.get_extra_info('peername')}\")\n-\n-    def connection_lost(self, exc):\n-        print(\"Client disconnected\")\n-\n-    def password_auth_supported(self):\n-        return True\n-\n-    def validate_password(self, username, password):\n-        return username == USERNAME and password == PASSWORD\n-\n-\n+\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n+\tdef connection_lost(A,exc):print('Client disconnected')\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(A,username,password):return username==USERNAME and password==PASSWORD\n async def custom_bash_process(process):\n-    \"\"\"Spawns a custom bash shell process\"\"\"\n-    env = os.environ.copy()\n-    env[\"TERM\"] = \"xterm-256color\"\n-\n-    bash_proc = await asyncio.create_subprocess_exec(\n-        SHELL,\n-        \"-i\",\n-        stdin=asyncio.subprocess.PIPE,\n-        stdout=asyncio.subprocess.PIPE,\n-        stderr=asyncio.subprocess.PIPE,\n-        env=env,\n-    )\n-\n-    async def read_output():\n-        while True:\n-            data = await bash_proc.stdout.read(1)\n-            if not data:\n-                break\n-            process.stdout.write(data)\n-\n-    async def read_input():\n-        while True:\n-            data = await process.stdin.read(1)\n-            if not data:\n-                break\n-            bash_proc.stdin.write(data)\n-\n-    await asyncio.gather(read_output(), read_input())\n-\n-\n-async def start_ssh_server():\n-    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n-    await asyncssh.create_server(\n-        lambda: CustomSSHServer(),\n-        host=HOST,\n-        port=PORT,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=custom_bash_process,\n-    )\n-    print(f\"SSH server running on {HOST}:{PORT}\")\n-\n-\n-if __name__ == \"__main__\":\n-    try:\n-        asyncio.run(start_ssh_server())\n-    except (OSError, asyncssh.Error) as e:\n-        print(f\"Error starting SSH server: {e}\")\n+\t'Spawns a custom bash shell process';A=process;B=os.environ.copy();B['TERM']='xterm-256color';C=await asyncio.create_subprocess_exec(SHELL,'-i',stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE,env=B)\n+\tasync def D():\n+\t\twhile True:\n+\t\t\tB=await C.stdout.read(1)\n+\t\t\tif not B:break\n+\t\t\tA.stdout.write(B)\n+\tasync def E():\n+\t\twhile True:\n+\t\t\tB=await A.stdin.read(1)\n+\t\t\tif not B:break\n+\t\t\tC.stdin.write(B)\n+\tawait asyncio.gather(D(),E())\n+async def start_ssh_server():'Starts the AsyncSSH server with Bash';await asyncssh.create_server(lambda:CustomSSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=custom_bash_process);print(f\"SSH server running on {HOST}:{PORT}\");await asyncio.Future()\n+if __name__=='__main__':\n+\ttry:asyncio.run(start_ssh_server())\n+\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SSH server: {e}\")\n\\ No newline at end of file\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex 4a09452..ef35691 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -1,74 +1,17 @@\n-\n-\n-import asyncio\n-import sys\n-\n-import asyncssh\n-\n-\n-async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-\n-    width, height, pixwidth, pixheight = process.term_size\n-\n-    process.stdout.write(\n-        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n-    )\n-    if pixwidth and pixheight:\n-        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n-    process.stdout.write(\"\\nTry resizing your window!\\n\")\n-\n-    while not process.stdin.at_eof():\n-        try:\n-            await process.stdin.read()\n-        except asyncssh.TerminalSizeChanged as exc:\n-            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n-            if exc.pixwidth and exc.pixheight:\n-                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n-            process.stdout.write(\"\\n\")\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.listen(\n-        \"\",\n-        2230,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+import asyncio,sys,asyncssh\n+async def handle_client(process):\n+\tA=process;E,F,C,D=A.term_size;A.stdout.write(f\"Terminal type: {A.term_type}, size: {E}x{F}\")\n+\tif C and D:A.stdout.write(f\" ({C}x{D} pixels)\")\n+\tA.stdout.write('\\nTry resizing your window!\\n')\n+\twhile not A.stdin.at_eof():\n+\t\ttry:await A.stdin.read()\n+\t\texcept asyncssh.TerminalSizeChanged as B:\n+\t\t\tA.stdout.write(f\"New window size: {B.width}x{B.height}\")\n+\t\t\tif B.pixwidth and B.pixheight:A.stdout.write(f\" ({B.pixwidth}x{B.pixheight} pixels)\")\n+\t\t\tA.stdout.write('\\n')\n+async def start_server():await asyncssh.listen('',2230,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex 187722c..eb4fe72 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -1,90 +1,24 @@\n-\n-\n-import asyncio\n-import sys\n+import asyncio,sys\n from typing import Optional\n-\n-import asyncssh\n-import bcrypt\n-\n-passwords = {\n-    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n-}\n-\n-\n-def handle_client(process: asyncssh.SSHServerProcess) -> None:\n-    username = process.get_extra_info(\"username\")\n-    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n-\n-\n+import asyncssh,bcrypt\n+passwords={'guest':b'','user':bcrypt.hashpw(b'user',bcrypt.gensalt())}\n+def handle_client(process):A=process;B=A.get_extra_info('username');A.stdout.write(f\"Welcome to my SSH server, {B}!\\n\")\n class MySSHServer(asyncssh.SSHServer):\n-    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n-        peername = conn.get_extra_info(\"peername\")[0]\n-        print(f\"SSH connection received from {peername}.\")\n-\n-    def connection_lost(self, exc: Optional[Exception]) -> None:\n-        if exc:\n-            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n-        else:\n-            print(\"SSH connection closed.\")\n-\n-    def begin_auth(self, username: str) -> bool:\n-        return passwords.get(username) != b\"\"\n-\n-    def password_auth_supported(self) -> bool:\n-        return True\n-\n-    def validate_password(self, username: str, password: str) -> bool:\n-        if username not in passwords:\n-            return False\n-        pw = passwords[username]\n-        if not password and not pw:\n-            return True\n-        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.create_server(\n-        MySSHServer,\n-        \"\",\n-        2231,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+\tdef connection_made(B,conn):A=conn.get_extra_info('peername')[0];print(f\"SSH connection received from {A}.\")\n+\tdef connection_lost(A,exc):\n+\t\tif exc:print('SSH connection error: '+str(exc),file=sys.stderr)\n+\t\telse:print('SSH connection closed.')\n+\tdef begin_auth(A,username):return passwords.get(username)!=b''\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(D,username,password):\n+\t\tA=password;B=username\n+\t\tif B not in passwords:return False\n+\t\tC=passwords[B]\n+\t\tif not A and not C:return True\n+\t\treturn bcrypt.checkpw(A.encode('utf-8'),C)\n+async def start_server():await asyncssh.create_server(MySSHServer,'',2231,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex cfd5d21..39f45e3 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -1,112 +1,28 @@\n-\n-\n-import asyncio\n-import sys\n-from typing import List, cast\n-\n+import asyncio,sys\n+from typing import List,cast\n import asyncssh\n-\n-\n class ChatClient:\n-    _clients: List[\"ChatClient\"] = []\n-\n-    def __init__(self, process: asyncssh.SSHServerProcess):\n-        self._process = process\n-\n-    @classmethod\n-    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n-        await cls(process).run()\n-\n-    async def readline(self) -> str:\n-        return cast(str, self._process.stdin.readline())\n-\n-    def write(self, msg: str) -> None:\n-        self._process.stdout.write(msg)\n-\n-    def broadcast(self, msg: str) -> None:\n-        for client in self._clients:\n-            if client != self:\n-                client.write(msg)\n-\n-    def begin_auth(self, username: str) -> bool:\n-\n-    def password_auth_supported(self) -> bool:\n-        return True\n-\n-    def validate_password(self, username: str, password: str) -> bool:\n-        return True\n-\n-    async def run(self) -> None:\n-        self.write(\"Welcome to chat!\\n\\n\")\n-\n-        self.write(\"Enter your name: \")\n-        name = (await self.readline()).rstrip(\"\\n\")\n-\n-        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n-\n-        self._clients.append(self)\n-        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n-\n-        try:\n-            async for line in self._process.stdin:\n-                self.broadcast(f\"{name}: {line}\")\n-        except asyncssh.BreakReceived:\n-            pass\n-\n-        self.broadcast(f\"*** {name} has left chat ***\\n\")\n-        self._clients.remove(self)\n-\n-\n-async def start_server() -> None:\n-    await asyncssh.listen(\n-        \"\",\n-        2235,\n-        server_host_keys=[\"ssh_host_key\"],\n-        process_factory=ChatClient.handle_client,\n-    )\n-\n-\n-loop = asyncio.new_event_loop()\n-\n-try:\n-    loop.run_until_complete(start_server())\n-except (OSError, asyncssh.Error) as exc:\n-    sys.exit(\"Error starting server: \" + str(exc))\n-\n-loop.run_forever()\n+\t_clients:List['ChatClient']=[]\n+\tdef __init__(A,process):A._process=process\n+\t@classmethod\n+\tasync def handle_client(A,process):await A(process).run()\n+\tasync def readline(A):return cast(str,A._process.stdin.readline())\n+\tdef write(A,msg):A._process.stdout.write(msg)\n+\tdef broadcast(A,msg):\n+\t\tfor B in A._clients:\n+\t\t\tif B!=A:B.write(msg)\n+\tdef begin_auth(A,username):return True\n+\tdef password_auth_supported(A):return True\n+\tdef validate_password(A,username,password):return True\n+\tasync def run(A):\n+\t\tA.write('Welcome to chat!\\n\\n');A.write('Enter your name: ');B=(await A.readline()).rstrip('\\n');A.write(f\"\\n{len(A._clients)} other users are connected.\\n\\n\");A._clients.append(A);A.broadcast(f\"*** {B} has entered chat ***\\n\")\n+\t\ttry:\n+\t\t\tasync for C in A._process.stdin:A.broadcast(f\"{B}: {C}\")\n+\t\texcept asyncssh.BreakReceived:pass\n+\t\tA.broadcast(f\"*** {B} has left chat ***\\n\");A._clients.remove(A)\n+async def start_server():await asyncssh.listen('',2235,server_host_keys=['ssh_host_key'],process_factory=ChatClient.handle_client)\n+loop=asyncio.new_event_loop()\n+try:loop.run_until_complete(start_server())\n+except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n+loop.run_forever()\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Initial file manager UI and basic repository view", "commit": "4c34d7eda58530eddb2c8b3479627180d6eeb248", "diff": "commit 4c34d7eda58530eddb2c8b3479627180d6eeb248\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 14:30:53 2025 +0200\n\n    New stuff.\n\ndiff --git a/src/snek/static/file-manager.css b/src/snek/static/file-manager.css\nnew file mode 100644\nindex 0000000..89b3eec\n--- /dev/null\n+++ b/src/snek/static/file-manager.css\n@@ -0,0 +1,41 @@\n+    .file-manager {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n+      gap: 16px;\n+      padding: 20px;\n+      font-family: Arial, sans-serif;\n+      max-width: 800px;\n+      margin: 0 auto;\n+      border-radius: 8px;\n+    }\n+    .file-tile {\n+      border-radius: 8px;\n+      overflow: hidden;\n+      text-align: center;\n+      padding: 10px;\n+      transition: transform 0.2s;\n+    }\n+    .file-tile:hover {\n+      transform: translateY(-5px);\n+    }\n+    .file-icon {\n+      font-size: 40px;\n+      margin-bottom: 10px;\n+    }\n+    .file-name {\n+      font-size: 14px;\n+      overflow-wrap: break-word;\n+    }\n+    .file-tile img {\n+      max-width: 80%;\n+      height: auto;\n+      margin-bottom: 10px;\n+      border-radius: 4px;\n+    }\n+\n+\ndiff --git a/src/snek/static/file-manager.js b/src/snek/static/file-manager.js\nnew file mode 100644\nindex 0000000..55dbac6\n--- /dev/null\n+++ b/src/snek/static/file-manager.js\n@@ -0,0 +1,100 @@\n+class FileBrowser extends HTMLElement {\n+  constructor() {\n+    super();\n+    this.attachShadow({ mode: \"open\" });\n+  }\n+\n+  connectedCallback() {\n+    this.renderShell();\n+    this.load();\n+  }\n+\n+  renderShell() {\n+    this.shadowRoot.innerHTML = `\n+      <style>\n+        :host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }\n+        nav    { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }\n+        .crumb  { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n+        .grid   { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }\n+        .tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }\n+        img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }\n+        .icon { font-size:48px; line-height:90px; }\n+      </style>\n+\n+      <nav>\n+        <button id=\"up\">\u2b05\ufe0f\u00a0Up</button>\n+        <span class=\"crumb\" id=\"crumb\"></span>\n+      </nav>\n+      <div class=\"grid\" id=\"grid\"></div>\n+      <nav>\n+        <button id=\"prev\">Prev</button>\n+        <button id=\"next\">Next</button>\n+      </nav>\n+    `;\n+    this.shadowRoot.getElementById(\"up\").addEventListener(\"click\", () => this.goUp());\n+    this.shadowRoot.getElementById(\"prev\").addEventListener(\"click\", () => {\n+      if (this.offset > 0) { this.offset -= this.limit; this.load(); }\n+    });\n+    this.shadowRoot.getElementById(\"next\").addEventListener(\"click\", () => {\n+      this.offset += this.limit; this.load();\n+    });\n+  }\n+\n+  async load() {\n+    const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`);\n+    if (!r.ok) { console.error(await r.text()); return; }\n+    const data = await r.json();\n+    this.renderTiles(data.items);\n+    this.updateNav(data.pagination);\n+  }\n+\n+  renderTiles(items) {\n+    const grid = this.shadowRoot.getElementById(\"grid\");\n+    grid.innerHTML = \"\";\n+    items.forEach(item => {\n+      const tile = document.createElement(\"div\");\n+      tile.className = \"tile\";\n+\n+      if (item.type === \"directory\") {\n+        tile.innerHTML = `<div class=\"icon\">\ud83d\udcc2</div><div>${item.name}</div>`;\n+        tile.addEventListener(\"click\", () => { this.path = item.path; this.offset = 0; this.load(); });\n+      } else {\n+        if (item.mimetype?.startsWith(\"image/\")) {\n+          tile.innerHTML = `<img class=\"thumb\" src=\"${item.url}\" alt=\"${item.name}\"><div>${item.name}</div>`;\n+        } else {\n+          tile.innerHTML = `<div class=\"icon\">\ud83d\udcc4</div><div>${item.name}</div>`;\n+        }\n+        tile.addEventListener(\"click\", () => window.open(item.url, \"_blank\"));\n+      }\n+\n+      grid.appendChild(tile);\n+    });\n+  }\n+\n+  updateNav({ offset, limit, total }) {\n+    this.shadowRoot.getElementById(\"crumb\").textContent = `/${this.path}`;\n+    this.shadowRoot.getElementById(\"prev\").disabled = offset === 0;\n+    this.shadowRoot.getElementById(\"next\").disabled = offset + limit >= total;\n+    this.shadowRoot.getElementById(\"up\").disabled   = this.path === \"\";\n+  }\n+\n+  goUp() {\n+    if (!this.path) return;\n+    this.path = this.path.split(\"/\").slice(0, -1).join(\"/\");\n+    this.offset = 0;\n+    this.load();\n+  }\n+}\n+\n+customElements.define(\"file-manager\", FileBrowser);\ndiff --git a/src/snek/templates/repository.html b/src/snek/templates/repository.html\nnew file mode 100644\nindex 0000000..0052dc5\n--- /dev/null\n+++ b/src/snek/templates/repository.html\n@@ -0,0 +1,82 @@\n+{% extends \"app.html\" %}\n+{% block header_text %}{{rel_path}}{% endblock %}\n+\n+{% block main %}\n+\n+ <style>\n+\n+    .file-list {\n+      display: flex;\n+      flex-direction: column;\n+      gap: 0.5rem;\n+      overflow-y: auto;\n+    }\n+\n+    .file-item {\n+      display: flex;\n+      align-items: center;\n+      padding: 1rem;\n+      border-radius: 0.5rem;\n+      transition: background 0.2s;\n+    }\n+\n+    .file-item:hover {\n+    }\n+\n+    .file-icon {\n+      flex: 0 0 30px;\n+      text-align: center;\n+      margin-right: 1rem;\n+    }\n+\n+    .file-name {\n+      flex: 1;\n+      font-weight: bold;\n+      white-space: nowrap;\n+      overflow: hidden;\n+      text-overflow: ellipsis;\n+    }\n+\n+    .file-type, .file-size {\n+      flex: 0 0 auto;\n+      margin-left: 1rem;\n+      font-size: 0.9rem;\n+    }\n+\n+    @media (max-width: 600px) {\n+      .file-item {\n+        flex-direction: column;\n+        align-items: flex-start;\n+        gap: 0.25rem;\n+      }\n+\n+      .file-type, .file-size {\n+        margin-left: 0;\n+      }\n+    }\n+  </style>\n+    <div class=\"container\">\n+  <div class=\"file-list\">\n+    {% for file in files %}\n+    <a href=\"/repository/{{username}}/{{repo_name}}/{{ file.path }}\" class=\"file-item\">\n+        <div class=\"file-icon\">\n+          {% if file.type == 'tree' %}\n+            <i class=\"fa fa-folder\"></i>\n+          {% else %}\n+            <i class=\"fa fa-file\"></i>\n+          {% endif %}\n+        </div>\n+        <div class=\"file-name\">{{ file.name }}</div>\n+        <div class=\"file-type\">{{ file.type }}</div>\n+        <div class=\"file-size\">{{ file.size }} B</div>\n+      </a>\n+    {% endfor %}\n+  </div>\n+  </div>\n+\n+  {% endblock %}\n+\ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nnew file mode 100644\nindex 0000000..f7a2e9d\n--- /dev/null\n+++ b/src/snek/view/repository.py\n@@ -0,0 +1,15 @@\n+from snek.system.view import BaseView\n+from aiohttp import web\n+class RepositoryView(BaseView):\n+\tasync def get(A):\n+\t\tG='type';H='name';I='.git';J='username';B=A.request.match_info[J];K=A.request.match_info['repo_name'];C=A.request.match_info.get('rel_path','')\n+\t\tif not B.count('-')==4:E=await A.services.user.get_by_username(B)\n+\t\telse:E=await A.services.user.get(B)\n+\t\tif not E:return web.HTTPNotFound()\n+\t\tB=E[J];M=await A.services.user.get_repository_path(E['uid'])\n+\t\tif C.endswith(I):C=C[:-4]\n+\t\tL=M.joinpath(K+I)\n+\t\tif not L.exists():return web.HTTPNotFound()\n+\t\timport os;from git import Repo;N=Repo(L.joinpath(C));F=[];O=[];P=N.head.commit\n+\t\tfor D in P.tree.traverse():F.append({H:D.name,'mode':D.mode,G:D.type,'path':D.path,'size':D.size})\n+\t\tsorted(F,key=lambda x:x[H]);sorted(F,key=lambda x:x[G],reverse=True);Q=f\"{B}/{C}\"[:-4];return await A.render_template('repository.html',dict(username=B,repo_name=K,rel_path=C,full_path=Q,files=F,directories=O))\n\\ No newline at end of file"}
{"repo": ".", "date": "2025-05-09", "line": "'NoneType' object is not subscriptable", "commit": "1616e4edb97284f705400c0598306202a083f60f", "diff": "commit 1616e4edb97284f705400c0598306202a083f60f\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Fri May 9 14:57:22 2025 +0200\n\n    revert 17c6124a57a394c63427a0038e598fdb40560f15\n    \n    revert Minify.\n\ndiff --git a/src/snek/__init__.py b/src/snek/__init__.py\nindex e69de29..8b13789 100644\n--- a/src/snek/__init__.py\n+++ b/src/snek/__init__.py\n@@ -0,0 +1 @@\n+\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 5d861d9..35e56e3 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,21 +1,32 @@\n-_D='Database path for the application'\n-_C='snek.db'\n-_B='--db_path'\n-_A=True\n-import click,uvloop\n+import click\n+import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n+\n @click.group()\n-def cli():0\n+def cli():\n+    pass\n+\n @cli.command()\n-@click.option('--port',default=8081,show_default=_A,help='Port to run the application on')\n-@click.option('--host',default='0.0.0.0',show_default=_A,help='Host to run the application on')\n-@click.option(_B,default=_C,show_default=_A,help=_D)\n+@click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n+@click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def serve(port, host, db_path):\n+    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    web.run_app(\n+    )\n+\n @cli.command()\n-@click.option(_B,default=_C,show_default=_A,help=_D)\n-def main():cli()\n-if __name__=='__main__':main()\n\\ No newline at end of file\n+@click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n+def shell(db_path):\n+    start_ipython(argv=[], user_ns={'app': app})\n+\n+def main():\n+    cli()\n+\n+if __name__ == \"__main__\":\n+    main()\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex f5f1948..ceb7c9d 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -1,31 +1,37 @@\n-_G='name'\n-_F='static'\n-_E='user'\n-_D=None\n-_C=True\n-_B='channel_uid'\n-_A='uid'\n-import asyncio,logging,pathlib,time,uuid\n+import asyncio\n+import logging\n+import pathlib\n+import time\n+import uuid\n+\n from snek.view.threads import ThreadsView\n+\n logging.basicConfig(level=logging.DEBUG)\n+\n from concurrent.futures import ThreadPoolExecutor\n+\n from aiohttp import web\n-from aiohttp_session import get_session as session_get,session_middleware,setup as session_setup\n+from aiohttp_session import (\n+    get_session as session_get,\n+    session_middleware,\n+    setup as session_setup,\n+)\n from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n+\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n from snek.system import http\n from snek.system.cache import Cache\n from snek.system.markdown import MarkdownExtension\n-from snek.system.middleware import auth_middleware,cors_middleware\n+from snek.system.middleware import auth_middleware, cors_middleware\n from snek.system.profiler import profiler_handler\n-from snek.system.template import EmojiExtension,LinkifyExtension,PythonExtension\n-from snek.view.about import AboutHTMLView,AboutMDView\n+from snek.system.template import EmojiExtension, LinkifyExtension, PythonExtension\n+from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.avatar import AvatarView\n-from snek.view.docs import DocsHTMLView,DocsMDView\n+from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.drive import DriveView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n@@ -42,79 +48,275 @@ from snek.view.settings.index import SettingsIndexView\n from snek.view.settings.profile import SettingsProfileView\n from snek.view.stats import StatsView\n from snek.view.status import StatusView\n-from snek.view.terminal import TerminalSocketView,TerminalView\n+from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-SESSION_KEY=b'c79a0c5fda4b424189c427d28c9f7c34'\n+\n+SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n+\n+\n @web.middleware\n-async def session_middleware(request,handler):A=request;setattr(A,'session',await session_get(A));B=await handler(A);return B\n+async def session_middleware(request, handler):\n+    setattr(request, \"session\", await session_get(request))\n+    response = await handler(request)\n+    return response\n+\n+\n @web.middleware\n-async def trailing_slash_middleware(request,handler):\n-\tA=request\n-\tif A.path and not A.path.endswith('/'):raise web.HTTPFound(A.path+'/')\n-\treturn await handler(A)\n+async def trailing_slash_middleware(request, handler):\n+    if request.path and not request.path.endswith(\"/\"):\n+        raise web.HTTPFound(request.path + \"/\")\n+    return await handler(request)\n+\n+\n class Application(BaseApplication):\n-\tdef __init__(A,*B,**C):D=[cors_middleware,web.normalize_path_middleware(merge_slashes=_C)];A.template_path=pathlib.Path(__file__).parent.joinpath('templates');A.static_path=pathlib.Path(__file__).parent.joinpath(_F);super().__init__(middlewares=D,template_path=A.template_path,client_max_size=5368709120*B,**C);session_setup(A,EncryptedCookieStorage(SESSION_KEY));A.tasks=asyncio.Queue();A._middlewares.append(session_middleware);A._middlewares.append(auth_middleware);A.jinja2_env.add_extension(MarkdownExtension);A.jinja2_env.add_extension(LinkifyExtension);A.jinja2_env.add_extension(PythonExtension);A.jinja2_env.add_extension(EmojiExtension);A.setup_router();A.executor=_D;A.cache=Cache(A);A.services=get_services(app=A);A.mappers=get_mappers(app=A);A.on_startup.append(A.prepare_asyncio);A.on_startup.append(A.prepare_database)\n-\tasync def prepare_asyncio(A,app):app.executor=ThreadPoolExecutor(max_workers=200);app.loop.set_default_executor(A.executor)\n-\tasync def create_task(A,task):await A.tasks.put(task)\n-\tasync def task_runner(A):\n-\t\twhile _C:\n-\t\t\tB=await A.tasks.get();A.db.begin()\n-\t\t\ttry:C=time.time();await B;D=time.time();print(f\"Task {B} took {D-C} seconds\");A.tasks.task_done()\n-\t\t\texcept Exception as E:print(E)\n-\t\t\tA.db.commit()\n-\tasync def prepare_database(A,app):\n-\t\tC='channel_message';D='channel_member';E='username';B='user_uid';A.db.query('PRAGMA journal_mode=WAL');A.db.query('PRAGMA syncnorm=off')\n-\t\ttry:\n-\t\t\tif not A.db[_E].has_index(E):A.db[_E].create_index(E,unique=_C)\n-\t\t\tif not A.db[D].has_index([_B,B]):A.db[D].create_index([_B,B])\n-\t\t\tif not A.db[C].has_index([_B,B]):A.db[C].create_index([_B,B])\n-\t\texcept:pass\n-\t\tawait app.services.drive.prepare_all();A.loop.create_task(A.task_runner())\n-\tdef setup_router(A):A.router.add_get('/',IndexView);A.router.add_static('/',pathlib.Path(__file__).parent.joinpath(_F),name=_F,show_index=_C);A.router.add_view('/profiler.html',profiler_handler);A.router.add_view('/about.html',AboutHTMLView);A.router.add_view('/about.md',AboutMDView);A.router.add_view('/logout.json',LogoutView);A.router.add_view('/logout.html',LogoutView);A.router.add_view('/docs.html',DocsHTMLView);A.router.add_view('/docs.md',DocsMDView);A.router.add_view('/status.json',StatusView);A.router.add_view('/settings/index.html',SettingsIndexView);A.router.add_view('/settings/profile.html',SettingsProfileView);A.router.add_view('/settings/profile.json',SettingsProfileView);A.router.add_view('/web.html',WebView);A.router.add_view('/login.html',LoginView);A.router.add_view('/login.json',LoginView);A.router.add_view('/register.html',RegisterView);A.router.add_view('/register.json',RegisterView);A.router.add_view('/drive/{rel_path:.*}',DriveView);A.router.add_view('/drive.bin',UploadView);A.router.add_view('/drive.bin/{uid}.{ext}',UploadView);A.router.add_view('/search-user.html',SearchUserView);A.router.add_view('/search-user.json',SearchUserView);A.router.add_view('/avatar/{uid}.svg',AvatarView);A.router.add_get('/http-get',A.handle_http_get);A.router.add_get('/http-photo',A.handle_http_photo);A.router.add_get('/rpc.ws',RPCView);A.router.add_view('/channel/{channel}.html',WebView);A.router.add_view('/threads.html',ThreadsView);A.router.add_view('/terminal.ws',TerminalSocketView);A.router.add_view('/terminal.html',TerminalView);A.router.add_view('/drive.json',DriveView);A.router.add_view('/drive/{drive}.json',DriveView);A.router.add_view('/stats.json',StatsView);A.router.add_view('/user/{user}.html',UserView);A.router.add_view('/repository/{username}/{repo_name}',RepositoryView);A.router.add_view('/repository/{username}/{repo_name}/{rel_path:.*}',RepositoryView);A.router.add_view('/settings/repositories/index.html',RepositoriesIndexView);A.router.add_view('/settings/repositories/create.html',RepositoriesCreateView);A.router.add_view('/settings/repositories/repository/{name}/update.html',RepositoriesUpdateView);A.router.add_view('/settings/repositories/repository/{name}/delete.html',RepositoriesDeleteView);A.webdav=WebdavApplication(A);A.git=GitApplication(A);A.add_subapp('/webdav',A.webdav);A.add_subapp('/git',A.git)\n-\tasync def handle_test(A,request):return await A.render_template('test.html',request,context={_G:'retoor'})\n-\tasync def handle_http_get(C,request):A=request.query.get('url');B=await http.get(A);return web.Response(body=B)\n-\tasync def handle_http_photo(C,request):A=request.query.get('url');B=await http.create_site_photo(A);return web.Response(body=B.read_bytes(),headers={'Content-Type':'image/png'})\n-\tasync def render_template(A,template,request,context=_D):\n-\t\tI='channels';J='new_count';K='color';L=template;F='last_message_on';D=request;C=context;G=[]\n-\t\tif not C:C={}\n-\t\tC['rid']=str(uuid.uuid4())\n-\t\tif D.session.get(_A):\n-\t\t\tasync for E in A.services.channel_member.find(user_uid=D.session.get(_A),deleted_at=_D,is_banned=False):\n-\t\t\t\tB={};M=await A.services.channel_member.get_other_dm_user(E[_B],D.session.get(_A));H=await E.get_channel();N=await H.get_last_message();O=_D\n-\t\t\t\tif N:P=await N.get_user();O=P[K]\n-\t\t\t\tB[K]=O;B[F]=H[F];B['is_private']=H['tag']=='dm'\n-\t\t\t\tif M:B[_G]=M['nick'];B[_A]=E[_B]\n-\t\t\t\telse:B[_G]=E['label'];B[_A]=E[_B]\n-\t\t\t\tB[J]=E[J];G.append(B)\n-\t\t\tG.sort(key=lambda x:x[F]or'',reverse=_C)\n-\t\t\tif I not in C:C[I]=G\n-\t\t\tif _E not in C:C[_E]=await A.services.user.get(D.session.get(_A))\n-\t\tA.template_path.joinpath(L);await A.services.user.get_template_path(D.session.get(_A));A.original_loader=A.jinja2_env.loader;A.jinja2_env.loader=await A.get_user_template_loader(D.session.get(_A));Q=await super().render_template(L,D,C);A.jinja2_env.loader=A.original_loader;return Q\n-\tasync def static_handler(B,request):\n-\t\tD=request;E=D.match_info.get('filename','');C=[];F=D.session.get(_A)\n-\t\tif F:\n-\t\t\tA=await B.services.user.get_static_path(F)\n-\t\t\tif A:C.append(A)\n-\t\tfor H in B.services.user.get_admin_uids():\n-\t\t\tA=await B.services.user.get_static_path(H)\n-\t\t\tif A:C.append(A)\n-\t\tC.append(B.static_path)\n-\t\tfor G in C:\n-\t\t\tif pathlib.Path(G).joinpath(E).exists():return web.FileResponse(pathlib.Path(G).joinpath(E))\n-\t\treturn web.HTTPNotFound()\n-\tasync def get_user_template_loader(B,uid=_D):\n-\t\tC=[]\n-\t\tfor D in B.services.user.get_admin_uids():\n-\t\t\tA=await B.services.user.get_template_path(D)\n-\t\t\tif A:C.append(A)\n-\t\tif uid:\n-\t\t\tA=await B.services.user.get_template_path(uid)\n-\t\t\tif A:C.append(A)\n-\t\tC.append(B.template_path);return FileSystemLoader(C)\n-async def main():await web._run_app(app,port=8081,host='0.0.0.0')\n-if __name__=='__main__':asyncio.run(main())\n\\ No newline at end of file\n+\n+    def __init__(self, *args, **kwargs):\n+        middlewares = [\n+            cors_middleware,\n+            web.normalize_path_middleware(merge_slashes=True),\n+        ]\n+        self.template_path = pathlib.Path(__file__).parent.joinpath(\"templates\")\n+        self.static_path = pathlib.Path(__file__).parent.joinpath(\"static\")\n+        super().__init__(\n+            middlewares=middlewares, template_path=self.template_path, client_max_size=1024*1024*1024*5 *args, **kwargs\n+        )\n+        session_setup(self, EncryptedCookieStorage(SESSION_KEY))\n+        self.tasks = asyncio.Queue()\n+        self._middlewares.append(session_middleware)\n+        self._middlewares.append(auth_middleware)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+        self.jinja2_env.add_extension(LinkifyExtension)\n+        self.jinja2_env.add_extension(PythonExtension)\n+        self.jinja2_env.add_extension(EmojiExtension)\n+\n+        self.setup_router()\n+        self.executor = None\n+        self.cache = Cache(self)\n+        self.services = get_services(app=self)\n+        self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.prepare_asyncio)\n+        self.on_startup.append(self.prepare_database)\n+\n+    async def prepare_asyncio(self, app):\n+        app.executor = ThreadPoolExecutor(max_workers=200)\n+        app.loop.set_default_executor(self.executor)\n+\n+    async def create_task(self, task):\n+        await self.tasks.put(task)\n+\n+    async def task_runner(self):\n+        while True:\n+            task = await self.tasks.get()\n+            self.db.begin()\n+            try:\n+                task_start = time.time()\n+                await task\n+                task_end = time.time()\n+                print(f\"Task {task} took {task_end - task_start} seconds\")\n+                self.tasks.task_done()\n+            except Exception as ex:\n+                print(ex)\n+            self.db.commit()\n+\n+    async def prepare_database(self, app):\n+        self.db.query(\"PRAGMA journal_mode=WAL\")\n+        self.db.query(\"PRAGMA syncnorm=off\")\n+\n+        try:\n+            if not self.db[\"user\"].has_index(\"username\"):\n+                self.db[\"user\"].create_index(\"username\", unique=True)\n+            if not self.db[\"channel_member\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_member\"].create_index([\"channel_uid\", \"user_uid\"])\n+            if not self.db[\"channel_message\"].has_index([\"channel_uid\", \"user_uid\"]):\n+                self.db[\"channel_message\"].create_index([\"channel_uid\", \"user_uid\"])\n+        except:\n+            pass\n+\n+        await app.services.drive.prepare_all()\n+        self.loop.create_task(self.task_runner())\n+\n+    def setup_router(self):\n+        self.router.add_get(\"/\", IndexView)\n+        self.router.add_static(\n+            \"/\",\n+            pathlib.Path(__file__).parent.joinpath(\"static\"),\n+            name=\"static\",\n+            show_index=True,\n+        )\n+        self.router.add_view(\"/profiler.html\", profiler_handler)\n+        self.router.add_view(\"/about.html\", AboutHTMLView)\n+        self.router.add_view(\"/about.md\", AboutMDView)\n+        self.router.add_view(\"/logout.json\", LogoutView)\n+        self.router.add_view(\"/logout.html\", LogoutView)\n+        self.router.add_view(\"/docs.html\", DocsHTMLView)\n+        self.router.add_view(\"/docs.md\", DocsMDView)\n+        self.router.add_view(\"/status.json\", StatusView)\n+        self.router.add_view(\"/settings/index.html\", SettingsIndexView)\n+        self.router.add_view(\"/settings/profile.html\", SettingsProfileView)\n+        self.router.add_view(\"/settings/profile.json\", SettingsProfileView)\n+        self.router.add_view(\"/web.html\", WebView)\n+        self.router.add_view(\"/login.html\", LoginView)\n+        self.router.add_view(\"/login.json\", LoginView)\n+        self.router.add_view(\"/register.html\", RegisterView)\n+        self.router.add_view(\"/register.json\", RegisterView)\n+        self.router.add_view(\"/drive/{rel_path:.*}\", DriveView)\n+        self.router.add_view(\"/drive.bin\", UploadView)\n+        self.router.add_view(\"/drive.bin/{uid}.{ext}\", UploadView)\n+        self.router.add_view(\"/search-user.html\", SearchUserView)\n+        self.router.add_view(\"/search-user.json\", SearchUserView)\n+        self.router.add_view(\"/avatar/{uid}.svg\", AvatarView)\n+        self.router.add_get(\"/http-get\", self.handle_http_get)\n+        self.router.add_get(\"/http-photo\", self.handle_http_photo)\n+        self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel}.html\", WebView)\n+        self.router.add_view(\"/threads.html\", ThreadsView)\n+        self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n+        self.router.add_view(\"/terminal.html\", TerminalView)\n+        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive/{drive}.json\", DriveView)\n+        self.router.add_view(\"/stats.json\", StatsView)\n+        self.router.add_view(\"/user/{user}.html\", UserView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n+        self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n+        self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\n+        self.router.add_view(\"/settings/repositories/repository/{name}/delete.html\", RepositoriesDeleteView)\n+        self.webdav = WebdavApplication(self)\n+        self.git = GitApplication(self)\n+        self.add_subapp(\"/webdav\", self.webdav)\n+        self.add_subapp(\"/git\",self.git)\n+        \n+    \n+    async def handle_test(self, request):\n+\n+        return await self.render_template(\n+            \"test.html\", request, context={\"name\": \"retoor\"}\n+        )\n+\n+    async def handle_http_get(self, request: web.Request):\n+        url = request.query.get(\"url\")\n+        content = await http.get(url)\n+        return web.Response(body=content)\n+\n+    async def handle_http_photo(self, request):\n+        url = request.query.get(\"url\")\n+        path = await http.create_site_photo(url)\n+        return web.Response(\n+            body=path.read_bytes(), headers={\"Content-Type\": \"image/png\"}\n+        )\n+\n+    async def render_template(self, template, request, context=None):\n+        channels = []\n+        if not context:\n+            context = {}\n+        context[\"rid\"] = str(uuid.uuid4())\n+        if request.session.get(\"uid\"):\n+            async for subscribed_channel in self.services.channel_member.find(\n+                user_uid=request.session.get(\"uid\"), deleted_at=None, is_banned=False\n+            ):\n+                item = {}\n+                other_user = await self.services.channel_member.get_other_dm_user(\n+                    subscribed_channel[\"channel_uid\"], request.session.get(\"uid\")\n+                )\n+                parent_object = await subscribed_channel.get_channel()\n+                last_message = await parent_object.get_last_message()\n+                color = None\n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                item[\"color\"] = color\n+                item[\"last_message_on\"] = parent_object[\"last_message_on\"]\n+                item[\"is_private\"] = parent_object[\"tag\"] == \"dm\"\n+                if other_user:\n+                    item[\"name\"] = other_user[\"nick\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                else:\n+                    item[\"name\"] = subscribed_channel[\"label\"]\n+                    item[\"uid\"] = subscribed_channel[\"channel_uid\"]\n+                item[\"new_count\"] = subscribed_channel[\"new_count\"]\n+\n+                channels.append(item)\n+\n+            channels.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+            if \"channels\" not in context:\n+                context[\"channels\"] = channels\n+            if \"user\" not in context:\n+                context[\"user\"] = await self.services.user.get(\n+                    request.session.get(\"uid\")\n+                )\n+\n+        self.template_path.joinpath(template)\n+\n+        await self.services.user.get_template_path(request.session.get(\"uid\"))\n+\n+        self.original_loader = self.jinja2_env.loader\n+\n+        self.jinja2_env.loader = await self.get_user_template_loader(\n+            request.session.get(\"uid\")\n+        )\n+\n+        rendered = await super().render_template(template, request, context)\n+\n+        self.jinja2_env.loader = self.original_loader\n+\n+        return rendered\n+\n+\n+    async def static_handler(self, request):\n+        file_name = request.match_info.get('filename', '')\n+\n+        paths = []\n+\n+        uid = request.session.get(\"uid\")\n+        if uid:\n+            user_static_path = await self.services.user.get_static_path(uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+      \n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_static_path = await self.services.user.get_static_path(admin_uid)\n+            if user_static_path:\n+                paths.append(user_static_path)\n+        \n+        paths.append(self.static_path)\n+\n+        for path in paths:\n+            if pathlib.Path(path).joinpath(file_name).exists():\n+                return web.FileResponse(pathlib.Path(path).joinpath(file_name))\n+        return web.HTTPNotFound()\n+\n+    async def get_user_template_loader(self, uid=None):\n+        template_paths = []\n+        for admin_uid in self.services.user.get_admin_uids():\n+            user_template_path = await self.services.user.get_template_path(admin_uid)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n+        if uid:\n+            user_template_path = await self.services.user.get_template_path(uid)\n+            if user_template_path:\n+                template_paths.append(user_template_path)\n+\n+\n+        template_paths.append(self.template_path)\n+        return FileSystemLoader(template_paths)\n+\n+\n+\n+\n+async def main():\n+    await web._run_app(app, port=8081, host=\"0.0.0.0\")\n+\n+\n+if __name__ == \"__main__\":\n+    asyncio.run(main())\ndiff --git a/src/snek/docs/app.py b/src/snek/docs/app.py\nindex b47df44..50a4245 100644\n--- a/src/snek/docs/app.py\n+++ b/src/snek/docs/app.py\n@@ -1,14 +1,43 @@\n import pathlib\n+\n from aiohttp import web\n from app.app import Application as BaseApplication\n+\n from snek.system.markdown import MarkdownExtension\n+\n+\n class Application(BaseApplication):\n-\tdef __init__(A,path=None,*B,**C):A.path=pathlib.Path(path);D=A.path;super().__init__(*B,template_path=D,**C);A.jinja2_env.add_extension(MarkdownExtension);A.router.add_get('/{tail:.*}',A.handle_document)\n-\tasync def handle_document(B,request):\n-\t\tD='text/plain';E=b'Resource is not found on this server.';F='index.html';G=request;C=G.match_info['tail'].strip('/')\n-\t\tif C=='':C=F\n-\t\tA=B.path.joinpath(C)\n-\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n-\t\tif A.is_dir():A=A.joinpath(F)\n-\t\tif not A.exists():return web.Response(status=404,body=E,content_type=D)\n-\t\tH=await B.render_template(str(A.relative_to(B.path)),G);return H\n\\ No newline at end of file\n+\n+    def __init__(self, path=None, *args, **kwargs):\n+        self.path = pathlib.Path(path)\n+        template_path = self.path\n+\n+        super().__init__(template_path=template_path, *args, **kwargs)\n+        self.jinja2_env.add_extension(MarkdownExtension)\n+\n+        self.router.add_get(\"/{tail:.*}\", self.handle_document)\n+\n+    async def handle_document(self, request):\n+        relative_path = request.match_info[\"tail\"].strip(\"/\")\n+        if relative_path == \"\":\n+            relative_path = \"index.html\"\n+        document_path = self.path.joinpath(relative_path)\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+        if document_path.is_dir():\n+            document_path = document_path.joinpath(\"index.html\")\n+        if not document_path.exists():\n+            return web.Response(\n+                status=404,\n+                body=b\"Resource is not found on this server.\",\n+                content_type=\"text/plain\",\n+            )\n+\n+        response = await self.render_template(\n+            str(document_path.relative_to(self.path)), request\n+        )\n+        return response\ndiff --git a/src/snek/dump.py b/src/snek/dump.py\nindex 2d52196..b254756 100644\n--- a/src/snek/dump.py\n+++ b/src/snek/dump.py\n@@ -1,12 +1,39 @@\n-_B='created_at'\n-_A='uid'\n import asyncio\n+\n from snek.app import app\n-async def fix_message(message):C='user';D='text';B='user_uid';A=message;A={_A:A[_A],B:A[B],D:A['message'],'sent':A[_B]};E=await app.services.user.get(uid=A[B]);A[C]=E and E['username']or None;return(A[C]or'')+': '+(A[D]or'')\n+\n+\n+async def fix_message(message):\n+    message = {\n+        \"uid\": message[\"uid\"],\n+        \"user_uid\": message[\"user_uid\"],\n+        \"text\": message[\"message\"],\n+        \"sent\": message[\"created_at\"],\n+    }\n+    user = await app.services.user.get(uid=message[\"user_uid\"])\n+    message[\"user\"] = user and user[\"username\"] or None\n+    return (message[\"user\"] or \"\") + \": \" + (message[\"text\"] or \"\")\n+\n+\n async def dump_public_channels():\n-\tA=[]\n-\tfor B in app.db['channel'].find(is_private=False,is_listed=True,tag='public'):print(f\"Dumping channel: {B[\"label\"]}.\");A+=[await fix_message(A)for A in app.db['channel_message'].find(channel_uid=B[_A],order_by=_B)];print('Dump succesfull!')\n-\tprint('Converting to json.');print('Converting succesful, now writing to dump.json')\n-\twith open('dump.txt','w')as C:C.write('\\n\\n'.join(A))\n-\tprint('Dump written to dump.json')\n-if __name__=='__main__':asyncio.run(dump_public_channels())\n\\ No newline at end of file\n+    result = []\n+    for channel in app.db[\"channel\"].find(\n+        is_private=False, is_listed=True, tag=\"public\"\n+    ):\n+        print(f\"Dumping channel: {channel['label']}.\")\n+        result += [\n+            await fix_message(record)\n+            for record in app.db[\"channel_message\"].find(\n+                channel_uid=channel[\"uid\"], order_by=\"created_at\"\n+            )\n+        ]\n+        print(\"Dump succesfull!\")\n+    print(\"Converting to json.\")\n+    print(\"Converting succesful, now writing to dump.json\")\n+    with open(\"dump.txt\", \"w\") as f:\n+        f.write(\"\\n\\n\".join(result))\n+    print(\"Dump written to dump.json\")\n+\n+\n+if __name__ == \"__main__\":\n+    asyncio.run(dump_public_channels())\ndiff --git a/src/snek/form/login.py b/src/snek/form/login.py\nindex c0b8cfd..ef13d67 100644\n--- a/src/snek/form/login.py\n+++ b/src/snek/form/login.py\n@@ -1,14 +1,51 @@\n-_B='username'\n-_A='password'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n class AuthField(FormInputElement):\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=await super().errors\n-\t\tif A.model.password.value and A.model.username.value:\n-\t\t\tif not await A.app.services.user.validate_login(A.model.username.value,A.model.password.value):return['Invalid username or password']\n-\t\treturn B\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.model.password.value and self.model.username.value:\n+            if not await self.app.services.user.validate_login(\n+                self.model.username.value, self.model.password.value\n+            ):\n+                return [\"Invalid username or password\"]\n+        return result\n+\n+\n class LoginForm(Form):\n-\ttitle=HTMLElement(tag='h1',text='Login');username=AuthField(name=_B,required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');password=AuthField(name=_A,required=True,min_length=1,type=_A,place_holder='Password');action=FormButtonElement(name='action',value='submit',text='Login',type='button')\n-\t@property\n-\tasync def is_valid(self):A=self;return all([A[_B],A[_A],not await A.username.errors,not await A.password.errors])\n\\ No newline at end of file\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Login\")\n+\n+    username = AuthField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\",\n+    )\n+    password = AuthField(\n+        name=\"password\",\n+        required=True,\n+        min_length=1,\n+        type=\"password\",\n+        place_holder=\"Password\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Login\", type=\"button\"\n+    )\n+\n+    @property\n+    async def is_valid(self):\n+        return all(\n+            [\n+                self[\"username\"],\n+                self[\"password\"],\n+                not await self.username.errors,\n+                not await self.password.errors,\n+            ]\n+        )\ndiff --git a/src/snek/form/register.py b/src/snek/form/register.py\nindex a9f8c71..b105696 100644\n--- a/src/snek/form/register.py\n+++ b/src/snek/form/register.py\n@@ -1,10 +1,44 @@\n-_B='password'\n-_A='Register'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n class UsernameField(FormInputElement):\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=await super().errors\n-\t\tif A.value and await A.app.services.user.count(username=A.value):B.append('Username is not available.')\n-\t\treturn B\n-class RegisterForm(Form):title=HTMLElement(tag='h1',text=_A);username=UsernameField(name='username',required=True,min_length=2,max_length=20,regex='^[a-zA-Z0-9_-]+$',place_holder='Username',type='text');email=FormInputElement(name='email',required=False,regex='^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$',place_holder='Email address',type='email');password=FormInputElement(name=_B,required=True,min_length=1,type=_B,place_holder='Password');action=FormButtonElement(name='action',value='submit',text=_A,type='button')\n\\ No newline at end of file\n+\n+    @property\n+    async def errors(self):\n+        result = await super().errors\n+        if self.value and await self.app.services.user.count(username=self.value):\n+            result.append(\"Username is not available.\")\n+        return result\n+\n+\n+class RegisterForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Register\")\n+\n+    username = UsernameField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-]+$\",\n+        place_holder=\"Username\",\n+        type=\"text\",\n+    )\n+    email = FormInputElement(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+        place_holder=\"Email address\",\n+        type=\"email\",\n+    )\n+    password = FormInputElement(\n+        name=\"password\",\n+        required=True,\n+        min_length=1,\n+        type=\"password\",\n+        place_holder=\"Password\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Register\", type=\"button\"\n+    )\ndiff --git a/src/snek/form/search_user.py b/src/snek/form/search_user.py\nindex bf6de66..7e946b9 100644\n--- a/src/snek/form/search_user.py\n+++ b/src/snek/form/search_user.py\n@@ -1,2 +1,18 @@\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n-class SearchUserForm(Form):title=HTMLElement(tag='h1',text='Search user');username=FormInputElement(name='username',required=True,min_length=1,max_length=128,place_holder='Username');action=FormButtonElement(name='action',value='submit',text='Search',type='button')\n\\ No newline at end of file\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SearchUserForm(Form):\n+\n+    title = HTMLElement(tag=\"h1\", text=\"Search user\")\n+\n+    username = FormInputElement(\n+        name=\"username\",\n+        required=True,\n+        min_length=1,\n+        max_length=128,\n+        place_holder=\"Username\",\n+    )\n+\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Search\", type=\"button\"\n+    )\ndiff --git a/src/snek/form/settings/profile.py b/src/snek/form/settings/profile.py\nindex 094c28e..836cd67 100644\n--- a/src/snek/form/settings/profile.py\n+++ b/src/snek/form/settings/profile.py\n@@ -1,5 +1,25 @@\n-_C='button'\n-_B='submit'\n-_A='action'\n-from snek.system.form import Form,FormButtonElement,FormInputElement,HTMLElement\n-class SettingsProfileForm(Form):nick=FormInputElement(name='nick',required=True,place_holder='Your Nickname',min_length=1,max_length=20);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C);title=HTMLElement(tag='h1',text='Profile');profile=FormInputElement(name='profile',place_holder='Tell about yourself.',required=False,max_length=300);action=FormButtonElement(name=_A,value=_B,text='Save',type=_C)\n\\ No newline at end of file\n+from snek.system.form import Form, FormButtonElement, FormInputElement, HTMLElement\n+\n+\n+class SettingsProfileForm(Form):\n+\n+    nick = FormInputElement(\n+        name=\"nick\",\n+        required=True,\n+        place_holder=\"Your Nickname\",\n+        min_length=1,\n+        max_length=20,\n+    )\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\n+    title = HTMLElement(tag=\"h1\", text=\"Profile\")\n+    profile = FormInputElement(\n+        name=\"profile\",\n+        place_holder=\"Tell about yourself.\",\n+        required=False,\n+        max_length=300,\n+    )\n+    action = FormButtonElement(\n+        name=\"action\", value=\"submit\", text=\"Save\", type=\"button\"\n+    )\ndiff --git a/src/snek/gunicorn.py b/src/snek/gunicorn.py\nindex b51f326..8583142 100644\n--- a/src/snek/gunicorn.py\n+++ b/src/snek/gunicorn.py\n@@ -1,2 +1,3 @@\n from snek.app import app\n-application=app\n\\ No newline at end of file\n+\n+application = app\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex 917ab7d..ab7904f 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -1,4 +1,5 @@\n import functools\n+\n from snek.mapper.channel import ChannelMapper\n from snek.mapper.channel_member import ChannelMemberMapper\n from snek.mapper.channel_message import ChannelMessageMapper\n@@ -9,6 +10,24 @@ from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_mappers(app=None):A=app;return Object(**{'user':UserMapper(app=A),'channel_member':ChannelMemberMapper(app=A),'channel':ChannelMapper(app=A),'channel_message':ChannelMessageMapper(app=A),'notification':NotificationMapper(app=A),'drive_item':DriveItemMapper(app=A),'drive':DriveMapper(app=A),'user_property':UserPropertyMapper(app=A),'repository':RepositoryMapper(app=A)})\n-def get_mapper(name,app=None):return get_mappers(app=app)[name]\n\\ No newline at end of file\n+def get_mappers(app=None):\n+    return Object(\n+        **{\n+            \"user\": UserMapper(app=app),\n+            \"channel_member\": ChannelMemberMapper(app=app),\n+            \"channel\": ChannelMapper(app=app),\n+            \"channel_message\": ChannelMessageMapper(app=app),\n+            \"notification\": NotificationMapper(app=app),\n+            \"drive_item\": DriveItemMapper(app=app),\n+            \"drive\": DriveMapper(app=app),\n+            \"user_property\": UserPropertyMapper(app=app),\n+            \"repository\": RepositoryMapper(app=app),\n+        }\n+    )\n+\n+\n+def get_mapper(name, app=None):\n+    return get_mappers(app=app)[name]\ndiff --git a/src/snek/mapper/channel.py b/src/snek/mapper/channel.py\nindex d663d5b..6239dc8 100644\n--- a/src/snek/mapper/channel.py\n+++ b/src/snek/mapper/channel.py\n@@ -1,3 +1,7 @@\n from snek.model.channel import ChannelModel\n from snek.system.mapper import BaseMapper\n-class ChannelMapper(BaseMapper):table_name='channel';model_class=ChannelModel\n\\ No newline at end of file\n+\n+\n+class ChannelMapper(BaseMapper):\n+    table_name = \"channel\"\n+    model_class = ChannelModel\ndiff --git a/src/snek/mapper/channel_member.py b/src/snek/mapper/channel_member.py\nindex b221d99..f0f62d6 100644\n--- a/src/snek/mapper/channel_member.py\n+++ b/src/snek/mapper/channel_member.py\n@@ -1,3 +1,7 @@\n from snek.model.channel_member import ChannelMemberModel\n from snek.system.mapper import BaseMapper\n-class ChannelMemberMapper(BaseMapper):table_name='channel_member';model_class=ChannelMemberModel\n\\ No newline at end of file\n+\n+\n+class ChannelMemberMapper(BaseMapper):\n+    table_name = \"channel_member\"\n+    model_class = ChannelMemberModel\ndiff --git a/src/snek/mapper/channel_message.py b/src/snek/mapper/channel_message.py\nindex c27a9cb..35ccbe9 100644\n--- a/src/snek/mapper/channel_message.py\n+++ b/src/snek/mapper/channel_message.py\n@@ -1,3 +1,7 @@\n from snek.model.channel_message import ChannelMessageModel\n from snek.system.mapper import BaseMapper\n-class ChannelMessageMapper(BaseMapper):model_class=ChannelMessageModel;table_name='channel_message'\n\\ No newline at end of file\n+\n+\n+class ChannelMessageMapper(BaseMapper):\n+    model_class = ChannelMessageModel\n+    table_name = \"channel_message\"\ndiff --git a/src/snek/mapper/drive.py b/src/snek/mapper/drive.py\nindex cac318a..c92c687 100644\n--- a/src/snek/mapper/drive.py\n+++ b/src/snek/mapper/drive.py\n@@ -1,3 +1,7 @@\n from snek.model.drive import DriveModel\n from snek.system.mapper import BaseMapper\n-class DriveMapper(BaseMapper):table_name='drive';model_class=DriveModel\n\\ No newline at end of file\n+\n+\n+class DriveMapper(BaseMapper):\n+    table_name = \"drive\"\n+    model_class = DriveModel\ndiff --git a/src/snek/mapper/drive_item.py b/src/snek/mapper/drive_item.py\nindex 96676f6..3d17a61 100644\n--- a/src/snek/mapper/drive_item.py\n+++ b/src/snek/mapper/drive_item.py\n@@ -1,3 +1,8 @@\n from snek.model.drive_item import DriveItemModel\n from snek.system.mapper import BaseMapper\n-class DriveItemMapper(BaseMapper):model_class=DriveItemModel;table_name='drive_item'\n\\ No newline at end of file\n+\n+\n+class DriveItemMapper(BaseMapper):\n+\n+    model_class = DriveItemModel\n+    table_name = \"drive_item\"\ndiff --git a/src/snek/mapper/notification.py b/src/snek/mapper/notification.py\nindex c2372ce..9bd74b5 100644\n--- a/src/snek/mapper/notification.py\n+++ b/src/snek/mapper/notification.py\n@@ -1,3 +1,7 @@\n from snek.model.notification import NotificationModel\n from snek.system.mapper import BaseMapper\n-class NotificationMapper(BaseMapper):table_name='notification';model_class=NotificationModel\n\\ No newline at end of file\n+\n+\n+class NotificationMapper(BaseMapper):\n+    table_name = \"notification\"\n+    model_class = NotificationModel\ndiff --git a/src/snek/mapper/repository.py b/src/snek/mapper/repository.py\nindex 1c04ba3..1ac10d4 100644\n--- a/src/snek/mapper/repository.py\n+++ b/src/snek/mapper/repository.py\n@@ -1,3 +1,7 @@\n from snek.model.repository import RepositoryModel\n from snek.system.mapper import BaseMapper\n-class RepositoryMapper(BaseMapper):model_class=RepositoryModel;table_name='repository'\n\\ No newline at end of file\n+\n+\n+class RepositoryMapper(BaseMapper):\n+    model_class = RepositoryModel\n+    table_name = \"repository\"\ndiff --git a/src/snek/mapper/user.py b/src/snek/mapper/user.py\nindex 1df0eea..e0df494 100644\n--- a/src/snek/mapper/user.py\n+++ b/src/snek/mapper/user.py\n@@ -1,7 +1,20 @@\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n+\n+\n class UserMapper(BaseMapper):\n-\ttable_name='user';model_class=UserModel\n-\tdef get_admin_uids(A):\n-\t\ttry:return[A['uid']for A in A.db.query('SELECT uid FROM user WHERE is_admin = :is_admin',{'is_admin':True})]\n-\t\texcept Exception as B:print(B);return[]\n\\ No newline at end of file\n+    table_name = \"user\"\n+    model_class = UserModel\n+\n+    def get_admin_uids(self):\n+        try:\n+            return [\n+                user[\"uid\"]\n+                for user in self.db.query(\n+                    \"SELECT uid FROM user WHERE is_admin = :is_admin\",\n+                    {\"is_admin\": True},\n+                )\n+            ]\n+        except Exception as ex:\n+            print(ex)\n+            return []\ndiff --git a/src/snek/mapper/user_property.py b/src/snek/mapper/user_property.py\nindex 654e769..7359f60 100644\n--- a/src/snek/mapper/user_property.py\n+++ b/src/snek/mapper/user_property.py\n@@ -1,3 +1,7 @@\n from snek.model.user_property import UserPropertyModel\n from snek.system.mapper import BaseMapper\n-class UserPropertyMapper(BaseMapper):table_name='user_property';model_class=UserPropertyModel\n\\ No newline at end of file\n+\n+\n+class UserPropertyMapper(BaseMapper):\n+    table_name = \"user_property\"\n+    model_class = UserPropertyModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex bb7fb2a..6399c89 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -1,6 +1,9 @@\n import functools\n+\n from snek.model.channel import ChannelModel\n from snek.model.channel_member import ChannelMemberModel\n+\n from snek.model.channel_message import ChannelMessageModel\n from snek.model.drive import DriveModel\n from snek.model.drive_item import DriveItemModel\n@@ -9,6 +12,24 @@ from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_models():return Object(**{'user':UserModel,'channel_member':ChannelMemberModel,'channel':ChannelModel,'channel_message':ChannelMessageModel,'drive_item':DriveItemModel,'drive':DriveModel,'notification':NotificationModel,'user_property':UserPropertyModel,'repository':RepositoryModel})\n-def get_model(name):return get_models()[name]\n\\ No newline at end of file\n+def get_models():\n+    return Object(\n+        **{\n+            \"user\": UserModel,\n+            \"channel_member\": ChannelMemberModel,\n+            \"channel\": ChannelModel,\n+            \"channel_message\": ChannelMessageModel,\n+            \"drive_item\": DriveItemModel,\n+            \"drive\": DriveModel,\n+            \"notification\": NotificationModel,\n+            \"user_property\": UserPropertyModel,\n+            \"repository\": RepositoryModel,\n+        }\n+    )\n+\n+\n+def get_model(name):\n+    return get_models()[name]\ndiff --git a/src/snek/model/channel.py b/src/snek/model/channel.py\nindex 939d658..0a90c39 100644\n--- a/src/snek/model/channel.py\n+++ b/src/snek/model/channel.py\n@@ -1,12 +1,30 @@\n-_C='uid'\n-_B=False\n-_A=True\n from snek.model.channel_message import ChannelMessageModel\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelModel(BaseModel):\n-\tlabel=ModelField(name='label',required=_A,kind=str);description=ModelField(name='description',required=_B,kind=str);tag=ModelField(name='tag',required=_B,kind=str);created_by_uid=ModelField(name='created_by_uid',required=_A,kind=str);is_private=ModelField(name='is_private',required=_A,kind=bool,value=_B);is_listed=ModelField(name='is_listed',required=_A,kind=bool,value=_A);index=ModelField(name='index',required=_A,kind=int,value=1000);last_message_on=ModelField(name='last_message_on',required=_B,kind=str)\n-\tasync def get_last_message(A):\n-\t\ttry:\n-\t\t\tasync for B in A.app.services.channel_message.query('SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1',{'channel_uid':A[_C]}):return await A.app.services.channel_message.get(uid=B[_C])\n-\t\texcept:pass\n-\tasync def get_members(A):return await A.app.services.channel_member.find(channel_uid=A[_C],deleted_at=None,is_banned=_B)\n\\ No newline at end of file\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    description = ModelField(name=\"description\", required=False, kind=str)\n+    tag = ModelField(name=\"tag\", required=False, kind=str)\n+    created_by_uid = ModelField(name=\"created_by_uid\", required=True, kind=str)\n+    is_private = ModelField(name=\"is_private\", required=True, kind=bool, value=False)\n+    is_listed = ModelField(name=\"is_listed\", required=True, kind=bool, value=True)\n+    index = ModelField(name=\"index\", required=True, kind=int, value=1000)\n+    last_message_on = ModelField(name=\"last_message_on\", required=False, kind=str)\n+\n+    async def get_last_message(self) -> ChannelMessageModel:\n+        try:\n+            async for model in self.app.services.channel_message.query(\n+                \"SELECT uid FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT 1\",\n+                {\"channel_uid\": self[\"uid\"]},\n+            ):\n+\n+                return await self.app.services.channel_message.get(uid=model[\"uid\"])\n+        except:\n+            pass\n+        return None\n+\n+    async def get_members(self):\n+        return await self.app.services.channel_member.find(\n+            channel_uid=self[\"uid\"], deleted_at=None, is_banned=False\n+        )\ndiff --git a/src/snek/model/channel_member.py b/src/snek/model/channel_member.py\nindex 09b7e91..54b0418 100644\n--- a/src/snek/model/channel_member.py\n+++ b/src/snek/model/channel_member.py\n@@ -1,19 +1,41 @@\n-_D='channel_uid'\n-_C='user_uid'\n-_B=False\n-_A=True\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelMemberModel(BaseModel):\n-\tlabel=ModelField(name='label',required=_A,kind=str);channel_uid=ModelField(name=_D,required=_A,kind=str);user_uid=ModelField(name=_C,required=_A,kind=str);is_moderator=ModelField(name='is_moderator',required=_A,kind=bool,value=_B);is_read_only=ModelField(name='is_read_only',required=_A,kind=bool,value=_B);is_muted=ModelField(name='is_muted',required=_A,kind=bool,value=_B);is_banned=ModelField(name='is_banned',required=_A,kind=bool,value=_B);new_count=ModelField(name='new_count',required=_B,kind=int,value=0)\n-\tasync def get_user(A):return await A.app.services.user.get(uid=A[_C])\n-\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_D])\n-\tasync def get_name(A):\n-\t\tB=await A.get_channel()\n-\t\tif B['tag']=='dm':C=await A.get_other_dm_user();return C['nick']\n-\t\treturn B['name']or A['label']\n-\tasync def get_other_dm_user(A):\n-\t\tB='uid';C=await A.get_channel()\n-\t\tif C['tag']!='dm':return\n-\t\tasync for D in A.app.services.channel_member.find(channel_uid=C[B]):\n-\t\t\tif D[B]!=A[B]:return await A.app.services.user.get(uid=D[_C])\n-\t\treturn await A.get_user()\n\\ No newline at end of file\n+    label = ModelField(name=\"label\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    is_moderator = ModelField(\n+        name=\"is_moderator\", required=True, kind=bool, value=False\n+    )\n+    is_read_only = ModelField(\n+        name=\"is_read_only\", required=True, kind=bool, value=False\n+    )\n+    is_muted = ModelField(name=\"is_muted\", required=True, kind=bool, value=False)\n+    is_banned = ModelField(name=\"is_banned\", required=True, kind=bool, value=False)\n+    new_count = ModelField(name=\"new_count\", required=False, kind=int, value=0)\n+\n+    async def get_user(self):\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\n+\n+    async def get_name(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] == \"dm\":\n+            user = await self.get_other_dm_user()\n+            return user[\"nick\"]\n+        return channel[\"name\"] or self[\"label\"]\n+\n+    async def get_other_dm_user(self):\n+        channel = await self.get_channel()\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+\n+        async for model in self.app.services.channel_member.find(\n+            channel_uid=channel[\"uid\"]\n+        ):\n+            if model[\"uid\"] != self[\"uid\"]:\n+                return await self.app.services.user.get(uid=model[\"user_uid\"])\n+        return await self.get_user()\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 0677d7c..524a8a4 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,8 +1,15 @@\n-_B='user_uid'\n-_A='channel_uid'\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class ChannelMessageModel(BaseModel):\n-\tchannel_uid=ModelField(name=_A,required=True,kind=str);user_uid=ModelField(name=_B,required=True,kind=str);message=ModelField(name='message',required=True,kind=str);html=ModelField(name='html',required=False,kind=str)\n-\tasync def get_user(A):return await A.app.services.user.get(uid=A[_B])\n-\tasync def get_channel(A):return await A.app.services.channel.get(uid=A[_A])\n\\ No newline at end of file\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    message = ModelField(name=\"message\", required=True, kind=str)\n+    html = ModelField(name=\"html\", required=False, kind=str)\n+\n+    async def get_user(self) -> UserModel:\n+        return await self.app.services.user.get(uid=self[\"user_uid\"])\n+\n+    async def get_channel(self):\n+        return await self.app.services.channel.get(uid=self[\"channel_uid\"])\ndiff --git a/src/snek/model/drive.py b/src/snek/model/drive.py\nindex 62a2846..df17d0f 100644\n--- a/src/snek/model/drive.py\n+++ b/src/snek/model/drive.py\n@@ -1,6 +1,14 @@\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveModel(BaseModel):\n-\tuser_uid=ModelField(name='user_uid',required=True);name=ModelField(name='name',required=False,type=str)\n-\t@property\n-\tasync def items(self):\n-\t\tasync for A in self.app.services.drive_item.find(drive_uid=self['uid']):yield A\n\\ No newline at end of file\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    name = ModelField(name=\"name\", required=False, type=str)\n+\n+    @property\n+    async def items(self):\n+        async for drive_item in self.app.services.drive_item.find(\n+            drive_uid=self[\"uid\"]\n+        ):\n+            yield drive_item\ndiff --git a/src/snek/model/drive_item.py b/src/snek/model/drive_item.py\nindex a4427f3..e2b55b4 100644\n--- a/src/snek/model/drive_item.py\n+++ b/src/snek/model/drive_item.py\n@@ -1,10 +1,21 @@\n-_B='name'\n-_A=True\n import mimetypes\n-from snek.system.model import BaseModel,ModelField\n+\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class DriveItemModel(BaseModel):\n-\tdrive_uid=ModelField(name='drive_uid',required=_A,kind=str);name=ModelField(name=_B,required=_A,kind=str);path=ModelField(name='path',required=_A,kind=str);file_type=ModelField(name='file_type',required=_A,kind=str);file_size=ModelField(name='file_size',required=_A,kind=int);is_available=ModelField(name='is_available',required=_A,kind=bool,initial_value=_A)\n-\t@property\n-\tdef extension(self):return self[_B].split('.')[-1]\n-\t@property\n-\tdef mime_type(self):A,B=mimetypes.guess_type(self[_B]);return A\n\\ No newline at end of file\n+    drive_uid = ModelField(name=\"drive_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    file_type = ModelField(name=\"file_type\", required=True, kind=str)\n+    file_size = ModelField(name=\"file_size\", required=True, kind=int)\n+    is_available = ModelField(name=\"is_available\", required=True, kind=bool, initial_value=True)\n+\n+    @property\n+    def extension(self):\n+        return self[\"name\"].split(\".\")[-1]\n+\n+    @property\n+    def mime_type(self):\n+        mimetype, _ = mimetypes.guess_type(self[\"name\"])\n+        return mimetype\ndiff --git a/src/snek/model/notification.py b/src/snek/model/notification.py\nindex a8453eb..6a12328 100644\n--- a/src/snek/model/notification.py\n+++ b/src/snek/model/notification.py\n@@ -1,3 +1,9 @@\n-_A=True\n-from snek.system.model import BaseModel,ModelField\n-class NotificationModel(BaseModel):object_uid=ModelField(name='object_uid',required=_A);object_type=ModelField(name='object_type',required=_A);message=ModelField(name='message',required=_A);user_uid=ModelField(name='user_uid',required=_A);read_at=ModelField(name='is_read',required=_A)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class NotificationModel(BaseModel):\n+    object_uid = ModelField(name=\"object_uid\", required=True)\n+    object_type = ModelField(name=\"object_type\", required=True)\n+    message = ModelField(name=\"message\", required=True)\n+    user_uid = ModelField(name=\"user_uid\", required=True)\n+    read_at = ModelField(name=\"is_read\", required=True)\ndiff --git a/src/snek/model/repository.py b/src/snek/model/repository.py\nindex 40ef94a..598cbb2 100644\n--- a/src/snek/model/repository.py\n+++ b/src/snek/model/repository.py\n@@ -1,3 +1,14 @@\n from snek.model.user import UserModel\n-from snek.system.model import BaseModel,ModelField\n-class RepositoryModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);is_private=ModelField(name='is_private',required=False,kind=bool)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class RepositoryModel(BaseModel):\n+\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    \n+    name = ModelField(name=\"name\", required=True, kind=str)\n+\n+    is_private = ModelField(name=\"is_private\", required=False, kind=bool)\n+\n+\n+    \ndiff --git a/src/snek/model/user.py b/src/snek/model/user.py\nindex 8572402..9869456 100644\n--- a/src/snek/model/user.py\n+++ b/src/snek/model/user.py\n@@ -1,17 +1,60 @@\n-_D='^[a-zA-Z0-9_-+/]+$'\n-_C=False\n-_B=True\n-_A='uid'\n-from snek.system.model import BaseModel,ModelField\n+from snek.system.model import BaseModel, ModelField\n+\n+\n class UserModel(BaseModel):\n-\tasync def get_property(A,name):\n-\t\tB=await A.app.services.user_property.find_one(user_uid=A[_A],name=name)\n-\t\tif B:return B['value']\n-\tasync def has_property(A,name):return await A.app.services.user_property.exists(user_uid=A[_A],name=name)\n-\tasync def set_property(A,name,value):\n-\t\tC=value;B=name\n-\t\tif not await A.has_property(B):await A.app.services.user_property.insert(user_uid=A[_A],name=B,value=C)\n-\t\telse:await A.app.services.user_property.update(user_uid=A[_A],name=B,value=C)\n-\tasync def get_channel_members(A):\n-\t\tasync for B in A.app.services.channel_member.find(user_uid=A[_A],is_banned=_C,deleted_at=None):yield B\n\\ No newline at end of file\n+\n+    username = ModelField(\n+        name=\"username\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n+    )\n+    nick = ModelField(\n+        name=\"nick\",\n+        required=True,\n+        min_length=2,\n+        max_length=20,\n+        regex=r\"^[a-zA-Z0-9_-+/]+$\",\n+    )\n+    color = ModelField(\n+    )\n+    email = ModelField(\n+        name=\"email\",\n+        required=False,\n+        regex=r\"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$\",\n+    )\n+    password = ModelField(name=\"password\", required=True, min_length=1)\n+\n+    last_ping = ModelField(name=\"last_ping\", required=False, kind=str)\n+\n+    is_admin = ModelField(name=\"is_admin\", required=False, kind=bool)\n+\n+    async def get_property(self, name):\n+        prop = await self.app.services.user_property.find_one(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+        if prop:\n+            return prop[\"value\"]\n+\n+    async def has_property(self, name):\n+        return await self.app.services.user_property.exists(\n+            user_uid=self[\"uid\"], name=name\n+        )\n+\n+    async def set_property(self, name, value):\n+        if not await self.has_property(name):\n+            await self.app.services.user_property.insert(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+        else:\n+            await self.app.services.user_property.update(\n+                user_uid=self[\"uid\"], name=name, value=value\n+            )\n+\n+    async def get_channel_members(self):\n+        async for channel_member in self.app.services.channel_member.find(\n+            user_uid=self[\"uid\"], is_banned=False, deleted_at=None\n+        ):\n+            yield channel_member\ndiff --git a/src/snek/model/user_property.py b/src/snek/model/user_property.py\nindex 77e5b25..1231423 100644\n--- a/src/snek/model/user_property.py\n+++ b/src/snek/model/user_property.py\n@@ -1,2 +1,7 @@\n-from snek.system.model import BaseModel,ModelField\n-class UserPropertyModel(BaseModel):user_uid=ModelField(name='user_uid',required=True,kind=str);name=ModelField(name='name',required=True,kind=str);value=ModelField(name='path',required=True,kind=str)\n\\ No newline at end of file\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class UserPropertyModel(BaseModel):\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    value = ModelField(name=\"path\", required=True, kind=str)\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex 583ef6c..be356dc 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -1,4 +1,5 @@\n import functools\n+\n from snek.service.channel import ChannelService\n from snek.service.channel_member import ChannelMemberService\n from snek.service.channel_message import ChannelMessageService\n@@ -12,6 +13,27 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n+\n+\n @functools.cache\n-def get_services(app):A=app;return Object(**{'user':UserService(app=A),'channel_member':ChannelMemberService(app=A),'channel':ChannelService(app=A),'channel_message':ChannelMessageService(app=A),'chat':ChatService(app=A),'socket':SocketService(app=A),'notification':NotificationService(app=A),'util':UtilService(app=A),'drive':DriveService(app=A),'drive_item':DriveItemService(app=A),'user_property':UserPropertyService(app=A),'repository':RepositoryService(app=A)})\n-def get_service(name,app=None):return get_services(app=app)[name]\n\\ No newline at end of file\n+def get_services(app):\n+    return Object(\n+        **{\n+            \"user\": UserService(app=app),\n+            \"channel_member\": ChannelMemberService(app=app),\n+            \"channel\": ChannelService(app=app),\n+            \"channel_message\": ChannelMessageService(app=app),\n+            \"chat\": ChatService(app=app),\n+            \"socket\": SocketService(app=app),\n+            \"notification\": NotificationService(app=app),\n+            \"util\": UtilService(app=app),\n+            \"drive\": DriveService(app=app),\n+            \"drive_item\": DriveItemService(app=app),\n+            \"user_property\": UserPropertyService(app=app),\n+            \"repository\": RepositoryService(app=app),\n+        }\n+    )\n+\n+\n+def get_service(name, app=None):\n+    return get_services(app=app)[name]\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex 8c39f6e..b90e66f 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -1,49 +1,108 @@\n-_F='channel_uid'\n-_E='public'\n-_D=True\n-_C='uid'\n-_B=None\n-_A=False\n from datetime import datetime\n+\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class ChannelService(BaseService):\n-\tmapper_name='channel'\n-\tasync def get(E,uid=_B,**A):\n-\t\tD='name';C=uid\n-\t\tif C:\n-\t\t\tA[_C]=C;B=await super().get(**A)\n-\t\t\tif B:return B\n-\t\t\tdel A[_C];A[D]=C;B=await super().get(**A)\n-\t\t\tif B:return B\n-\t\t\tif B:return B\n-\t\t\treturn\n-\t\treturn await super().get(**A)\n-\tasync def create(C,label,created_by_uid,description=_B,tag=_B,is_private=_A,is_listed=_D):\n-\t\tE=is_listed;D=tag;B=label\n-\t\tF=await C.count(deleted_at=_B)\n-\t\tif not D and not F:D=_E\n-\t\tA=await C.new();A['label']=B;A['description']=description;A['tag']=D;A['created_by_uid']=created_by_uid;A['is_private']=is_private;A['is_listed']=E\n-\t\tif await C.save(A):return A\n-\t\traise Exception(f\"Failed to create channel: {A.errors}.\")\n-\tasync def get_dm(A,user1,user2):\n-\t\tC=user2;B=user1;D=await A.services.channel_member.get_dm(B,C)\n-\t\tif D:return await A.get(uid=D[_F])\n-\t\tE=await A.create('DM',B,tag='dm');await A.services.channel_member.create_dm(E[_C],B,C);return E\n-\tasync def get_users(A,channel_uid):\n-\t\tasync for C in A.services.channel_member.find(channel_uid=channel_uid,is_banned=_A,is_muted=_A,deleted_at=_B):\n-\t\t\tB=await A.services.user.get(uid=C['user_uid'])\n-\t\t\tif B:yield B\n-\tasync def get_online_users(C,channel_uid):\n-\t\tB='last_ping'\n-\t\tasync for A in C.get_users(channel_uid):\n-\t\t\tif not A[B]:continue\n-\t\t\tif(datetime.fromisoformat(now())-datetime.fromisoformat(A[B])).total_seconds()<20:yield A\n-\tasync def get_for_user(A,user_uid):\n-\t\tasync for B in A.services.channel_member.find(user_uid=user_uid,is_banned=_A,deleted_at=_B):C=await A.get(uid=B[_F]);yield C\n-\tasync def ensure_public_channel(B,created_by_uid):\n-\t\tC=created_by_uid;A=await B.get(is_listed=_D,tag=_E);D=_A\n-\t\tif not A:D=_D;A=await B.create(_E,created_by_uid=C,is_listed=_D,tag=_E)\n-\t\tawait B.app.services.channel_member.create(A[_C],C,is_moderator=D,is_read_only=_A,is_muted=_A,is_banned=_A);return A\n\\ No newline at end of file\n+    mapper_name = \"channel\"\n+\n+    async def get(self, uid=None, **kwargs):\n+        if uid:\n+            kwargs[\"uid\"] = uid\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            del kwargs[\"uid\"]\n+            kwargs[\"name\"] = uid\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            result = await super().get(**kwargs)\n+            if result:\n+                return result\n+            return None\n+        return await super().get(**kwargs)\n+\n+    async def create(\n+        self,\n+        label,\n+        created_by_uid,\n+        description=None,\n+        tag=None,\n+        is_private=False,\n+        is_listed=True,\n+    ):\n+        count = await self.count(deleted_at=None)\n+        if not tag and not count:\n+            tag = \"public\"\n+        model = await self.new()\n+        model[\"label\"] = label\n+        model[\"description\"] = description\n+        model[\"tag\"] = tag\n+        model[\"created_by_uid\"] = created_by_uid\n+        model[\"is_private\"] = is_private\n+        model[\"is_listed\"] = is_listed\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel: {model.errors}.\")\n+\n+    async def get_dm(self, user1, user2):\n+        channel_member = await self.services.channel_member.get_dm(user1, user2)\n+        if channel_member:\n+            return await self.get(uid=channel_member[\"channel_uid\"])\n+        channel = await self.create(\"DM\", user1, tag=\"dm\")\n+        await self.services.channel_member.create_dm(channel[\"uid\"], user1, user2)\n+        return channel\n+\n+    async def get_users(self, channel_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_uid,\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            user = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if user:\n+                yield user\n+\n+    async def get_online_users(self, channel_uid):\n+        async for user in self.get_users(channel_uid):\n+            if not user[\"last_ping\"]:\n+                continue\n+\n+            if (\n+                datetime.fromisoformat(now())\n+                - datetime.fromisoformat(user[\"last_ping\"])\n+            ).total_seconds() < 20:\n+                yield user\n+\n+    async def get_for_user(self, user_uid):\n+        async for channel_member in self.services.channel_member.find(\n+            user_uid=user_uid,\n+            is_banned=False,\n+            deleted_at=None,\n+        ):\n+            channel = await self.get(uid=channel_member[\"channel_uid\"])\n+            yield channel\n+\n+    async def ensure_public_channel(self, created_by_uid):\n+        model = await self.get(is_listed=True, tag=\"public\")\n+        is_moderator = False\n+        if not model:\n+            is_moderator = True\n+            model = await self.create(\n+                \"public\", created_by_uid=created_by_uid, is_listed=True, tag=\"public\"\n+            )\n+        await self.app.services.channel_member.create(\n+            model[\"uid\"],\n+            created_by_uid,\n+            is_moderator=is_moderator,\n+            is_read_only=False,\n+            is_muted=False,\n+            is_banned=False,\n+        )\n+        return model\ndiff --git a/src/snek/service/channel_member.py b/src/snek/service/channel_member.py\nindex cd3a62b..df96786 100644\n--- a/src/snek/service/channel_member.py\n+++ b/src/snek/service/channel_member.py\n@@ -1,28 +1,74 @@\n-_C='user_uid'\n-_B='channel_uid'\n-_A=False\n from snek.system.service import BaseService\n+\n+\n class ChannelMemberService(BaseService):\n-\tmapper_name='channel_member'\n-\tasync def mark_as_read(A,channel_uid,user_uid):B=await A.get(channel_uid=channel_uid,user_uid=user_uid);B['new_count']=0;return await A.save(B)\n-\tasync def get_user_uids(A,channel_uid):\n-\t\tasync for B in A.mapper.query('SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid',{_B:channel_uid}):yield B[_C]\n-\tasync def create(B,channel_uid,user_uid,is_moderator=_A,is_read_only=_A,is_muted=_A,is_banned=_A):\n-\t\tD='label';E='is_banned';F=user_uid;C=channel_uid;A=await B.get(channel_uid=C,user_uid=F)\n-\t\tif A:\n-\t\t\tif A[E]:return _A\n-\t\t\treturn A\n-\t\tA=await B.new();G=await B.services.channel.get(uid=C);A[D]=G[D];A[_B]=C;A[_C]=F;A['is_moderator']=is_moderator;A['is_read_only']=is_read_only;A['is_muted']=is_muted;A[E]=is_banned\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create channel member: {A.errors}.\")\n-\tasync def get_dm(D,from_user,to_user):\n-\t\tE='to_user';F='from_user';A=to_user;B=from_user\n-\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n-\t\tif not B==A:return\n-\t\tasync for C in D.query(\"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",{F:B,E:A}):return C\n-\tasync def get_other_dm_user(A,channel_uid,user_uid):\n-\t\tB='uid';C=channel_uid;D=await A.get(channel_uid=C,user_uid=user_uid);F=await A.services.channel.get(uid=D[_B])\n-\t\tif F['tag']!='dm':return\n-\t\tasync for E in A.services.channel_member.find(channel_uid=C):\n-\t\t\tif E[B]!=D[B]:return await A.services.user.get(uid=E[_C])\n-\tasync def create_dm(A,channel_uid,from_user_uid,to_user_uid):B=channel_uid;C=await A.create(B,from_user_uid);await A.create(B,to_user_uid);return C\n\\ No newline at end of file\n+\n+    mapper_name = \"channel_member\"\n+\n+    async def mark_as_read(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel_member[\"new_count\"] = 0\n+        return await self.save(channel_member)\n+\n+    async def get_user_uids(self, channel_uid):\n+        async for model in self.mapper.query(\n+            \"SELECT user_uid FROM channel_member WHERE channel_uid=:channel_uid\",\n+            {\"channel_uid\": channel_uid},\n+        ):\n+            yield model[\"user_uid\"]\n+\n+    async def create(\n+        self,\n+        channel_uid,\n+        user_uid,\n+        is_moderator=False,\n+        is_read_only=False,\n+        is_muted=False,\n+        is_banned=False,\n+    ):\n+        model = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        if model:\n+            if model[\"is_banned\"]:\n+                return False\n+            return model\n+        model = await self.new()\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        model[\"label\"] = channel[\"label\"]\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"is_moderator\"] = is_moderator\n+        model[\"is_read_only\"] = is_read_only\n+        model[\"is_muted\"] = is_muted\n+        model[\"is_banned\"] = is_banned\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel member: {model.errors}.\")\n+\n+    async def get_dm(self, from_user, to_user):\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') INNER JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = channel.uid AND channel_member2.user_uid = :to_user)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+            return model\n+        if not from_user == to_user:\n+            return None\n+        async for model in self.query(\n+            \"SELECT channel_member.* FROM channel_member INNER JOIN channel ON (channel.uid = channel_member.channel_uid and channel.tag = 'dm') LEFT JOIN channel_member AS channel_member2 ON(channel_member2.channel_uid = NULL AND channel_member2.user_uid = NULL)   WHERE channel_member.user_uid=:from_user \",\n+            {\"from_user\": from_user, \"to_user\": to_user},\n+        ):\n+\n+            return model\n+\n+    async def get_other_dm_user(self, channel_uid, user_uid):\n+        channel_member = await self.get(channel_uid=channel_uid, user_uid=user_uid)\n+        channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+        if channel[\"tag\"] != \"dm\":\n+            return None\n+        async for model in self.services.channel_member.find(channel_uid=channel_uid):\n+            if model[\"uid\"] != channel_member[\"uid\"]:\n+                return await self.services.user.get(uid=model[\"user_uid\"])\n+\n+    async def create_dm(self, channel_uid, from_user_uid, to_user_uid):\n+        result = await self.create(channel_uid, from_user_uid)\n+        await self.create(channel_uid, to_user_uid)\n+        return result\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex 1841024..f8a000f 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -1,33 +1,93 @@\n-_I='user_nick'\n-_H='created_at'\n-_G='html'\n-_F='uid'\n-_E='message'\n-_D='color'\n-_C='username'\n-_B='user_uid'\n-_A='channel_uid'\n from snek.system.service import BaseService\n+\n+\n class ChannelMessageService(BaseService):\n-\tmapper_name='channel_message'\n-\tasync def create(B,channel_uid,user_uid,message):\n-\t\tE=user_uid;A=await B.new();A[_A]=channel_uid;A[_B]=E;A[_E]=message;D={};F=A.record;D.update(F);C=await B.app.services.user.get(uid=E);D.update({_B:C[_F],_C:C[_C],_I:C['nick'],_D:C[_D]})\n-\t\ttry:G=B.app.jinja2_env.get_template('message.html');A[_G]=G.render(**D)\n-\t\texcept Exception as H:print(H,flush=True)\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create channel message: {A.errors}.\")\n-\tasync def to_extended_dict(C,message):\n-\t\tA=message;B=await C.services.user.get(uid=A[_B])\n-\t\tif not B:return{}\n-\t\treturn{_F:A[_F],_D:B[_D],_B:A[_B],_A:A[_A],_I:B['nick'],_E:A[_E],_H:A[_H],_G:A[_G],_C:B[_C]}\n-\tasync def offset(D,channel_uid,page=0,timestamp=None,page_size=30):\n-\t\tJ='timestamp';E='offset';F='page_size';G=timestamp;H=channel_uid;C=page_size;A=[];I=page*C\n-\t\ttry:\n-\t\t\tif G:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I,J:G}):A.append(B)\n-\t\t\telif page>0:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size',{_A:H,F:C,E:I,J:G}):A.append(B)\n-\t\t\telse:\n-\t\t\t\tasync for B in D.query('SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset',{_A:H,F:C,E:I}):A.append(B)\n-\t\texcept:pass\n-\t\tA.sort(key=lambda x:x[_H]);return A\n\\ No newline at end of file\n+    mapper_name = \"channel_message\"\n+\n+    async def create(self, channel_uid, user_uid, message):\n+        model = await self.new()\n+\n+        model[\"channel_uid\"] = channel_uid\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n+\n+        context = {}\n+\n+        record = model.record\n+        context.update(record)\n+        user = await self.app.services.user.get(uid=user_uid)\n+        context.update(\n+            {\n+                \"user_uid\": user[\"uid\"],\n+                \"username\": user[\"username\"],\n+                \"user_nick\": user[\"nick\"],\n+                \"color\": user[\"color\"],\n+            }\n+        )\n+        try:\n+            template = self.app.jinja2_env.get_template(\"message.html\")\n+            model[\"html\"] = template.render(**context)\n+        except Exception as ex:\n+            print(ex, flush=True)\n+\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create channel message: {model.errors}.\")\n+\n+    async def to_extended_dict(self, message):\n+        user = await self.services.user.get(uid=message[\"user_uid\"])\n+        if not user:\n+            return {}\n+        return {\n+            \"uid\": message[\"uid\"],\n+            \"color\": user[\"color\"],\n+            \"user_uid\": message[\"user_uid\"],\n+            \"channel_uid\": message[\"channel_uid\"],\n+            \"user_nick\": user[\"nick\"],\n+            \"message\": message[\"message\"],\n+            \"created_at\": message[\"created_at\"],\n+            \"html\": message[\"html\"],\n+            \"username\": user[\"username\"],\n+        }\n+\n+    async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n+        results = []\n+        offset = page * page_size\n+        try:\n+            if timestamp:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid AND created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n+                    results.append(model)\n+            elif page > 0:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid WHERE created_at < :timestamp ORDER BY created_at DESC LIMIT :page_size\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                        \"timestamp\": timestamp,\n+                    },\n+                ):\n+                    results.append(model)\n+            else:\n+                async for model in self.query(\n+                    \"SELECT * FROM channel_message WHERE channel_uid=:channel_uid ORDER BY created_at DESC LIMIT :page_size OFFSET :offset\",\n+                    {\n+                        \"channel_uid\": channel_uid,\n+                        \"page_size\": page_size,\n+                        \"offset\": offset,\n+                    },\n+                ):\n+                    results.append(model)\n+\n+        except:\n+            pass\n+        results.sort(key=lambda x: x[\"created_at\"])\n+        return results\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 14a9ad1..388d5c0 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -1,7 +1,39 @@\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class ChatService(BaseService):\n-\tasync def send(A,user_uid,channel_uid,message):\n-\t\tH='username';I='created_at';J='color';K='html';L='message';D='uid';E=user_uid;C=channel_uid;F=await A.services.channel.get(uid=C)\n-\t\tif not F:raise Exception('Channel not found.')\n-\t\tB=await A.services.channel_message.create(C,E,message);M=B[D];G=await A.services.user.get(uid=E);F['last_message_on']=now();await A.services.channel.save(F);await A.services.socket.broadcast(C,{L:B[L],K:B[K],'user_uid':E,J:G[J],'channel_uid':C,I:B[I],'updated_at':None,H:G[H],D:B[D],'user_nick':G['nick']});await A.app.create_task(A.services.notification.create_channel_message(M));return True\n\\ No newline at end of file\n+\n+    async def send(self, user_uid, channel_uid, message):\n+        channel = await self.services.channel.get(uid=channel_uid)\n+        if not channel:\n+            raise Exception(\"Channel not found.\")\n+        channel_message = await self.services.channel_message.create(\n+            channel_uid, user_uid, message\n+        )\n+        channel_message_uid = channel_message[\"uid\"]\n+\n+        user = await self.services.user.get(uid=user_uid)\n+        channel[\"last_message_on\"] = now()\n+        await self.services.channel.save(channel)\n+\n+        await self.services.socket.broadcast(\n+            channel_uid,\n+            {\n+                \"message\": channel_message[\"message\"],\n+                \"html\": channel_message[\"html\"],\n+                \"user_uid\": user_uid,\n+                \"color\": user[\"color\"],\n+                \"channel_uid\": channel_uid,\n+                \"created_at\": channel_message[\"created_at\"],\n+                \"updated_at\": None,\n+                \"username\": user[\"username\"],\n+                \"uid\": channel_message[\"uid\"],\n+                \"user_nick\": user[\"nick\"],\n+            },\n+        )\n+        await self.app.create_task(\n+            self.services.notification.create_channel_message(channel_message_uid)\n+        )\n+\n+        return True\ndiff --git a/src/snek/service/drive.py b/src/snek/service/drive.py\nindex e38b3fa..38035c7 100644\n--- a/src/snek/service/drive.py\n+++ b/src/snek/service/drive.py\n@@ -1,41 +1,153 @@\n-_H='Documents'\n-_G='Archives'\n-_F='Videos'\n-_E='Pictures'\n-_D='uid'\n-_C='user_uid'\n-_B='My Drive'\n-_A='name'\n from snek.system.service import BaseService\n+\n+\n class DriveService(BaseService):\n-\tmapper_name='drive';EXTENSIONS_PICTURES=['jpg','jpeg','png','gif','svg','webp','tiff'];EXTENSIONS_VIDEOS=['mp4','m4v','mov','wmv','webm','mkv','mpg','mpeg','avi','ogv','ogg','flv','3gp','3g2'];EXTENSIONS_ARCHIVES=['zip','rar','7z','tar','tar.gz','tar.xz','tar.bz2','tar.lzma','tar.lz'];EXTENSIONS_AUDIO=['mp3','wav','ogg','flac','m4a','wma','aac','opus','aiff','au','mid','midi'];EXTENSIONS_DOCS=['pdf','doc','docx','xls','xlsx','ppt','pptx','txt','md','json','csv','xml','html','css','js','py','sql','rs','toml','yml','yaml','ini','conf','config','log','csv','tsv','java','cs','csproj','scss','less','sass','json','lock','lock.json','jsonl']\n-\tasync def get_drive_name_by_extension(B,extension):\n-\t\tA=extension\n-\t\tif A.startswith('.'):A=A[1:]\n-\t\tif A in B.EXTENSIONS_PICTURES:return _E\n-\t\tif A in B.EXTENSIONS_VIDEOS:return _F\n-\t\tif A in B.EXTENSIONS_ARCHIVES:return _G\n-\t\tif A in B.EXTENSIONS_AUDIO:return'Audio'\n-\t\tif A in B.EXTENSIONS_DOCS:return _H\n-\t\treturn _B\n-\tasync def get_drive_by_extension(A,user_uid,extension):B=await A.get_drive_name_by_extension(extension);return await A.get_or_create(user_uid=user_uid,name=B)\n-\tasync def get_by_user(C,user_uid,name=None):\n-\t\tB=name;D={_C:user_uid}\n-\t\tasync for A in C.find(**D):\n-\t\t\tif not B:yield A\n-\t\t\telif A[_A]==B:yield A\n-\t\t\telif not A[_A]and B==_B:A[_A]=_B;await C.save(A);yield A\n-\tasync def get_or_create(B,user_uid,name=None,extensions=None):\n-\t\tD=user_uid;C=name;E={_C:D}\n-\t\tif C:E[_A]=C\n-\t\tasync for A in B.get_by_user(**E):return A\n-\t\tA=await B.new();A[_C]=D;A[_A]=C;await B.save(A);return A\n-\tasync def prepare_default_drives(B):\n-\t\tC='drive_uid'\n-\t\tasync for A in B.services.drive_item.find():\n-\t\t\tE=A.extension;D=await B.get_drive_by_extension(A[_C],E)\n-\t\t\tif not A[C]==D[_D]:A[C]=D[_D];await B.services.drive_item.save(A)\n-\tasync def prepare_default_drives_for_user(A,user_uid):B=user_uid;await A.get_or_create(user_uid=B,name=_B);await A.get_or_create(user_uid=B,name='Shared Drive');await A.get_or_create(user_uid=B,name=_E);await A.get_or_create(user_uid=B,name=_F);await A.get_or_create(user_uid=B,name=_G);await A.get_or_create(user_uid=B,name=_H)\n-\tasync def prepare_all(A):\n-\t\tawait A.prepare_default_drives()\n-\t\tasync for B in A.services.user.find():await A.prepare_default_drives_for_user(B[_D])\n\\ No newline at end of file\n+\n+    mapper_name = \"drive\"\n+\n+    EXTENSIONS_PICTURES = [\"jpg\", \"jpeg\", \"png\", \"gif\", \"svg\", \"webp\", \"tiff\"]\n+    EXTENSIONS_VIDEOS = [\n+        \"mp4\",\n+        \"m4v\",\n+        \"mov\",\n+        \"wmv\",\n+        \"webm\",\n+        \"mkv\",\n+        \"mpg\",\n+        \"mpeg\",\n+        \"avi\",\n+        \"ogv\",\n+        \"ogg\",\n+        \"flv\",\n+        \"3gp\",\n+        \"3g2\",\n+    ]\n+    EXTENSIONS_ARCHIVES = [\n+        \"zip\",\n+        \"rar\",\n+        \"7z\",\n+        \"tar\",\n+        \"tar.gz\",\n+        \"tar.xz\",\n+        \"tar.bz2\",\n+        \"tar.lzma\",\n+        \"tar.lz\",\n+    ]\n+    EXTENSIONS_AUDIO = [\n+        \"mp3\",\n+        \"wav\",\n+        \"ogg\",\n+        \"flac\",\n+        \"m4a\",\n+        \"wma\",\n+        \"aac\",\n+        \"opus\",\n+        \"aiff\",\n+        \"au\",\n+        \"mid\",\n+        \"midi\",\n+    ]\n+    EXTENSIONS_DOCS = [\n+        \"pdf\",\n+        \"doc\",\n+        \"docx\",\n+        \"xls\",\n+        \"xlsx\",\n+        \"ppt\",\n+        \"pptx\",\n+        \"txt\",\n+        \"md\",\n+        \"json\",\n+        \"csv\",\n+        \"xml\",\n+        \"html\",\n+        \"css\",\n+        \"js\",\n+        \"py\",\n+        \"sql\",\n+        \"rs\",\n+        \"toml\",\n+        \"yml\",\n+        \"yaml\",\n+        \"ini\",\n+        \"conf\",\n+        \"config\",\n+        \"log\",\n+        \"csv\",\n+        \"tsv\",\n+        \"java\",\n+        \"cs\",\n+        \"csproj\",\n+        \"scss\",\n+        \"less\",\n+        \"sass\",\n+        \"json\",\n+        \"lock\",\n+        \"lock.json\",\n+        \"jsonl\",\n+    ]\n+\n+    async def get_drive_name_by_extension(self, extension):\n+        if extension.startswith(\".\"):\n+            extension = extension[1:]\n+        if extension in self.EXTENSIONS_PICTURES:\n+            return \"Pictures\"\n+        if extension in self.EXTENSIONS_VIDEOS:\n+            return \"Videos\"\n+        if extension in self.EXTENSIONS_ARCHIVES:\n+            return \"Archives\"\n+        if extension in self.EXTENSIONS_AUDIO:\n+            return \"Audio\"\n+        if extension in self.EXTENSIONS_DOCS:\n+            return \"Documents\"\n+        return \"My Drive\"\n+\n+    async def get_drive_by_extension(self, user_uid, extension):\n+        name = await self.get_drive_name_by_extension(extension)\n+        return await self.get_or_create(user_uid=user_uid, name=name)\n+\n+    async def get_by_user(self, user_uid, name=None):\n+        kwargs = {\"user_uid\": user_uid}\n+        async for model in self.find(**kwargs):\n+            if not name:\n+                yield model\n+            elif model[\"name\"] == name:\n+                yield model\n+            elif not model[\"name\"] and name == \"My Drive\":\n+                model[\"name\"] = \"My Drive\"\n+                await self.save(model)\n+                yield model\n+\n+    async def get_or_create(self, user_uid, name=None, extensions=None):\n+        kwargs = {\"user_uid\": user_uid}\n+        if name:\n+            kwargs[\"name\"] = name\n+        async for model in self.get_by_user(**kwargs):\n+            return model\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        await self.save(model)\n+        return model\n+\n+    async def prepare_default_drives(self):\n+        async for drive_item in self.services.drive_item.find():\n+            extension = drive_item.extension\n+            drive = await self.get_drive_by_extension(drive_item[\"user_uid\"], extension)\n+            if not drive_item[\"drive_uid\"] == drive[\"uid\"]:\n+                drive_item[\"drive_uid\"] = drive[\"uid\"]\n+                await self.services.drive_item.save(drive_item)\n+\n+    async def prepare_default_drives_for_user(self, user_uid):\n+        await self.get_or_create(user_uid=user_uid, name=\"My Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Shared Drive\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Pictures\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Videos\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Archives\")\n+        await self.get_or_create(user_uid=user_uid, name=\"Documents\")\n+\n+    async def prepare_all(self):\n+        await self.prepare_default_drives()\n+        async for user in self.services.user.find():\n+            await self.prepare_default_drives_for_user(user[\"uid\"])\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex 0740949..ce747c1 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -1,7 +1,19 @@\n from snek.system.service import BaseService\n+\n+\n class DriveItemService(BaseService):\n-\tmapper_name='drive_item'\n-\tasync def create(B,drive_uid,name,path,type_,size):\n-\t\tA=await B.new();A['drive_uid']=drive_uid;A['name']=name;A['path']=str(path);A['extension']=str(name).split('.')[-1];A['file_type']=type_;A['file_size']=size\n-\t\tif await B.save(A):return A\n-\t\tC=await A.errors;raise Exception(f\"Failed to create drive item: {C}.\")\n\\ No newline at end of file\n+\n+    mapper_name = \"drive_item\"\n+\n+    async def create(self, drive_uid, name, path, type_, size):\n+        model = await self.new()\n+        model[\"drive_uid\"] = drive_uid\n+        model[\"name\"] = name\n+        model[\"path\"] = str(path)\n+        model[\"extension\"] = str(name).split(\".\")[-1]\n+        model[\"file_type\"] = type_\n+        model[\"file_size\"] = size\n+        if await self.save(model):\n+            return model\n+        errors = await model.errors\n+        raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/notification.py b/src/snek/service/notification.py\nindex 968d426..a22e8ae 100644\n--- a/src/snek/service/notification.py\n+++ b/src/snek/service/notification.py\n@@ -1,28 +1,65 @@\n-_E='message'\n-_D='object_type'\n-_C='object_uid'\n-_B=False\n-_A='user_uid'\n from snek.system.model import now\n from snek.system.service import BaseService\n+\n+\n class NotificationService(BaseService):\n-\tmapper_name='notification'\n-\tasync def mark_as_read(B,user_uid,channel_message_uid):\n-\t\tA=await B.get(user_uid,object_uid=channel_message_uid)\n-\t\tif not A:return _B\n-\t\tA['read_at']=now();await B.save(A);return True\n-\tasync def get_unread_stats(A,user_uid):await A.query('SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type',{_A:user_uid})\n-\tasync def create(B,object_uid,object_type,user_uid,message):\n-\t\tA=await B.new();A[_C]=object_uid;A[_D]=object_type;A[_A]=user_uid;A[_E]=message\n-\t\tif await B.save(A):return A\n-\t\traise Exception(f\"Failed to create notification: {A.errors}.\")\n-\tasync def create_channel_message(A,channel_message_uid):\n-\t\tE=channel_message_uid;D='new_count';F=await A.services.channel_message.get(uid=E);G=await A.services.user.get(uid=F[_A]);A.app.db.begin()\n-\t\tasync for B in A.services.channel_member.find(channel_uid=F['channel_uid'],is_banned=_B,is_muted=_B,deleted_at=None):\n-\t\t\tif not B[D]:B[D]=0\n-\t\t\tB[D]+=1;H=await A.services.user.get(uid=B[_A])\n-\t\t\tif not H:continue\n-\t\t\tawait A.services.channel_member.save(B);C=await A.new();C[_C]=E;C[_D]='channel_message';C[_A]=B[_A];C[_E]=f\"New message from {G[\"nick\"]} in {B[\"label\"]}.\"\n-\t\t\ttry:await A.save(C)\n-\t\t\texcept Exception:raise Exception(f\"Failed to create notification: {C.errors}.\")\n-\t\tA.app.db.commit()\n\\ No newline at end of file\n+    mapper_name = \"notification\"\n+\n+    async def mark_as_read(self, user_uid, channel_message_uid):\n+        model = await self.get(user_uid, object_uid=channel_message_uid)\n+        if not model:\n+            return False\n+        model[\"read_at\"] = now()\n+        await self.save(model)\n+        return True\n+\n+    async def get_unread_stats(self, user_uid):\n+        await self.query(\n+            \"SELECT object_type, COUNT(*) as count FROM notification WHERE user_uid=:user_uid AND read_at IS NULL GROUP BY object_type\",\n+            {\"user_uid\": user_uid},\n+        )\n+\n+    async def create(self, object_uid, object_type, user_uid, message):\n+        model = await self.new()\n+        model[\"object_uid\"] = object_uid\n+        model[\"object_type\"] = object_type\n+        model[\"user_uid\"] = user_uid\n+        model[\"message\"] = message\n+        if await self.save(model):\n+            return model\n+        raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+    async def create_channel_message(self, channel_message_uid):\n+        channel_message = await self.services.channel_message.get(\n+            uid=channel_message_uid\n+        )\n+        user = await self.services.user.get(uid=channel_message[\"user_uid\"])\n+        self.app.db.begin()\n+        async for channel_member in self.services.channel_member.find(\n+            channel_uid=channel_message[\"channel_uid\"],\n+            is_banned=False,\n+            is_muted=False,\n+            deleted_at=None,\n+        ):\n+            if not channel_member[\"new_count\"]:\n+                channel_member[\"new_count\"] = 0\n+            channel_member[\"new_count\"] += 1\n+\n+            usr = await self.services.user.get(uid=channel_member[\"user_uid\"])\n+            if not usr:\n+                continue\n+            await self.services.channel_member.save(channel_member)\n+\n+            model = await self.new()\n+            model[\"object_uid\"] = channel_message_uid\n+            model[\"object_type\"] = \"channel_message\"\n+            model[\"user_uid\"] = channel_member[\"user_uid\"]\n+            model[\"message\"] = (\n+                f\"New message from {user['nick']} in {channel_member['label']}.\"\n+            )\n+            try:\n+                await self.save(model)\n+            except Exception:\n+                raise Exception(f\"Failed to create notification: {model.errors}.\")\n+\n+        self.app.db.commit()\ndiff --git a/src/snek/service/repository.py b/src/snek/service/repository.py\nindex be30602..120c232 100644\n--- a/src/snek/service/repository.py\n+++ b/src/snek/service/repository.py\n@@ -1,23 +1,52 @@\n-_B='user_uid'\n-_A=False\n from snek.system.service import BaseService\n-import asyncio,shutil\n+import asyncio \n+import shutil\n+\n class RepositoryService(BaseService):\n-\tmapper_name='repository'\n-\tasync def delete(B,user_uid,name):\n-\t\tA=user_uid;C=asyncio.get_event_loop();D=(await B.services.user.get_repository_path(A)).joinpath(name)\n-\t\ttry:await C.run_in_executor(None,shutil.rmtree,D)\n-\t\texcept Exception as E:print(E)\n-\t\tawait super().delete(user_uid=A,name=name)\n-\tasync def exists(B,user_uid,name,**A):A[_B]=user_uid;A['name']=name;return await super().exists(**A)\n-\tasync def init(D,user_uid,name):\n-\t\tB='.git';A=await D.services.user.get_repository_path(user_uid)\n-\t\tif not A.exists():A.mkdir(parents=True)\n-\t\tA=A.joinpath(name);A=str(A)\n-\t\tif not A.endswith(B):A+=B\n-\t\tE=['git','init','--bare',A];C=await asyncio.subprocess.create_subprocess_exec(*E,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);F,G=await C.communicate();return C.returncode==0\n-\tasync def create(A,user_uid,name,is_private=_A):\n-\t\tC=name;D=user_uid\n-\t\tif await A.exists(user_uid=D,name=C):return _A\n-\t\tif not await A.init(user_uid=D,name=C):return _A\n-\t\tB=await A.new();B[_B]=D;B['name']=C;B['is_private']=is_private;return await A.save(B)\n\\ No newline at end of file\n+    mapper_name = \"repository\"\n+\n+    async def delete(self, user_uid, name):\n+        loop = asyncio.get_event_loop()\n+        repository_path = (await self.services.user.get_repository_path(user_uid)).joinpath(name)\n+        try:\n+            await loop.run_in_executor(None, shutil.rmtree, repository_path)\n+        except Exception as ex:\n+            print(ex)\n+\n+        await super().delete(user_uid=user_uid, name=name)\n+\n+\n+    async def exists(self, user_uid, name, **kwargs):\n+        kwargs[\"user_uid\"] = user_uid\n+        kwargs[\"name\"] = name\n+        return await super().exists(**kwargs)\n+\n+    async def init(self, user_uid, name):\n+        repository_path = await self.services.user.get_repository_path(user_uid)\n+        if not repository_path.exists():\n+            repository_path.mkdir(parents=True)\n+        repository_path = repository_path.joinpath(name)\n+        repository_path = str(repository_path)\n+        if not repository_path.endswith(\".git\"):\n+            repository_path += \".git\"\n+        command = ['git', 'init', '--bare', repository_path]\n+        process = await asyncio.subprocess.create_subprocess_exec(\n+            *command,\n+            stdout=asyncio.subprocess.PIPE,\n+            stderr=asyncio.subprocess.PIPE\n+        )\n+        stdout, stderr = await process.communicate()\n+        return process.returncode == 0\n+\n+    async def create(self, user_uid, name,is_private=False):\n+        if await self.exists(user_uid=user_uid, name=name):\n+            return False \n+\n+        if not await self.init(user_uid=user_uid, name=name):\n+            return False\n+\n+        model = await self.new()\n+        model[\"user_uid\"] = user_uid\n+        model[\"name\"] = name\n+        model[\"is_private\"] = is_private\n+        return await self.save(model)\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 86c83a8..a3654d2 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,36 +1,71 @@\n-_B=False\n-_A=True\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n+\n+\n class SocketService(BaseService):\n-\tclass Socket:\n-\t\tdef __init__(A,ws,user):A.ws=ws;A.is_connected=_A;A.user=user\n-\t\tasync def send_json(A,data):\n-\t\t\tif not A.is_connected:return _B\n-\t\t\ttry:await A.ws.send_json(data)\n-\t\t\texcept Exception:A.is_connected=_B\n-\t\t\treturn A.is_connected\n-\t\tasync def close(A):\n-\t\t\tif not A.is_connected:return _A\n-\t\t\tawait A.ws.close();A.is_connected=_B;return _A\n-\tdef __init__(A,app):super().__init__(app);A.sockets=set();A.users={};A.subscriptions={}\n-\tasync def add(A,ws,user_uid):\n-\t\tB=user_uid;C=A.Socket(ws,await A.app.services.user.get(uid=B));A.sockets.add(C)\n-\t\tif not A.users.get(B):A.users[B]=set()\n-\t\tA.users[B].add(C)\n-\tasync def subscribe(A,ws,channel_uid,user_uid):\n-\t\tB=channel_uid\n-\t\tif B not in A.subscriptions:A.subscriptions[B]=set()\n-\t\tC=A.Socket(ws,await A.app.services.user.get(uid=user_uid));A.subscriptions[B].add(C)\n-\tasync def send_to_user(B,user_uid,message):\n-\t\tA=0\n-\t\tfor C in B.users.get(user_uid,[]):\n-\t\t\tif await C.send_json(message):A+=1\n-\t\treturn A\n-\tasync def broadcast(A,channel_uid,message):\n-\t\ttry:\n-\t\t\tasync for B in A.services.channel_member.get_user_uids(channel_uid):print(B,flush=_A);await A.send_to_user(B,message)\n-\t\texcept Exception as C:print(C,flush=_A)\n-\t\treturn _A\n-\tasync def delete(A,ws):\n-\t\tfor B in[A for A in A.sockets if A.ws==ws]:await B.close();A.sockets.remove(B)\n\\ No newline at end of file\n+\n+    class Socket:\n+        def __init__(self, ws, user: UserModel):\n+            self.ws = ws\n+            self.is_connected = True\n+            self.user = user\n+\n+        async def send_json(self, data):\n+            if not self.is_connected:\n+                return False\n+            try:\n+                await self.ws.send_json(data)\n+            except Exception:\n+                self.is_connected = False\n+            return self.is_connected\n+\n+        async def close(self):\n+            if not self.is_connected:\n+                return True\n+\n+            await self.ws.close()\n+            self.is_connected = False\n+\n+            return True\n+\n+    def __init__(self, app):\n+        super().__init__(app)\n+        self.sockets = set()\n+        self.users = {}\n+        self.subscriptions = {}\n+\n+    async def add(self, ws, user_uid):\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.sockets.add(s)\n+        if not self.users.get(user_uid):\n+            self.users[user_uid] = set()\n+        self.users[user_uid].add(s)\n+\n+    async def subscribe(self, ws, channel_uid, user_uid):\n+        if channel_uid not in self.subscriptions:\n+            self.subscriptions[channel_uid] = set()\n+        s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n+        self.subscriptions[channel_uid].add(s)\n+\n+    async def send_to_user(self, user_uid, message):\n+        count = 0\n+        for s in self.users.get(user_uid, []):\n+            if await s.send_json(message):\n+                count += 1\n+        return count\n+\n+    async def broadcast(self, channel_uid, message):\n+        try:\n+            async for user_uid in self.services.channel_member.get_user_uids(\n+                channel_uid\n+            ):\n+                print(user_uid, flush=True)\n+                await self.send_to_user(user_uid, message)\n+        except Exception as ex:\n+            print(ex, flush=True)\n+        return True\n+\n+    async def delete(self, ws):\n+        for s in [sock for sock in self.sockets if sock.ws == ws]:\n+            await s.close()\n+            self.sockets.remove(s)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 6ece3fd..76e6d1c 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -1,53 +1,91 @@\n-_B='color'\n-_A=True\n import pathlib\n+\n from snek.system import security\n from snek.system.service import BaseService\n+\n+\n class UserService(BaseService):\n-\tmapper_name='user'\n-\tasync def get_by_username(A,username):return await A.get(username=username)\n-\tasync def search(C,query,**D):\n-\t\tA=query;A=A.strip().lower()\n-\t\tif not A:return[]\n-\t\tB=[]\n-\t\tasync for E in C.find(username={'ilike':'%'+A+'%'},**D):B.append(E)\n-\t\treturn B\n-\tasync def validate_login(C,username,password):\n-\t\tA=False;B=await C.get(username=username)\n-\t\tif not B:return A\n-\t\tif not await security.verify(password,B['password']):return A\n-\t\treturn _A\n-\tasync def save(B,user):\n-\t\tA=user\n-\t\tif not A[_B]:A[_B]=await B.services.util.random_light_hex_color()\n-\t\treturn await super().save(A)\n-\tasync def authenticate(B,username,password):\n-\t\tC=password;A=username;print(A,C,flush=_A);D=await B.validate_login(A,C);print(D,flush=_A)\n-\t\tif not D:return\n-\t\tE=await B.get(username=A,deleted_at=None);return E\n-\tdef get_admin_uids(A):return A.mapper.get_admin_uids()\n-\tasync def get_repository_path(A,user_uid):return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n-\tasync def get_static_path(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n-\t\tif not A.exists():return\n-\t\treturn A\n-\tasync def get_template_path(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n-\t\tif not A.exists():return\n-\t\treturn A\n-\tasync def get_home_folder(B,user_uid):\n-\t\tA=pathlib.Path(f\"./drive/{user_uid}\")\n-\t\tif not A.exists():\n-\t\t\ttry:A.mkdir(parents=_A,exist_ok=_A)\n-\t\t\texcept:pass\n-\t\treturn A\n-\tasync def register(B,email,username,password):\n-\t\tC=username\n-\t\tif await B.exists(username=C):raise Exception('User already exists.')\n-\t\tA=await B.new();A['nick']=C;A[_B]=await B.services.util.random_light_hex_color();A.email.value=email;A.username.value=C;A.password.value=await security.hash(password)\n-\t\tif await B.save(A):\n-\t\t\tif A:\n-\t\t\t\tD=await B.services.channel.ensure_public_channel(A['uid'])\n-\t\t\t\tif not D:raise Exception('Failed to create public channel.')\n-\t\t\treturn A\n-\t\traise Exception(f\"Failed to create user: {A.errors}.\")\n\\ No newline at end of file\n+    mapper_name = \"user\"\n+\n+    async def get_by_username(self, username):\n+        return await self.get(username=username)\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            return []\n+        results = []\n+        async for result in self.find(username={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\n+\n+    async def validate_login(self, username, password):\n+        model = await self.get(username=username)\n+        if not model:\n+            return False\n+        if not await security.verify(password, model[\"password\"]):\n+            return False\n+        return True\n+\n+    async def save(self, user):\n+        if not user[\"color\"]:\n+            user[\"color\"] = await self.services.util.random_light_hex_color()\n+        return await super().save(user)\n+\n+    async def authenticate(self, username, password):\n+        print(username, password, flush=True)\n+        success = await self.validate_login(username, password)\n+        print(success, flush=True)\n+        if not success:\n+            return None\n+\n+        model = await self.get(username=username, deleted_at=None)\n+        return model\n+\n+    def get_admin_uids(self):\n+        return self.mapper.get_admin_uids()\n+\n+    async def get_repository_path(self, user_uid):\n+        return pathlib.Path(f\"./drive/repositories/{user_uid}\")\n+\n+    async def get_static_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/static\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+\n+\n+    async def get_template_path(self, user_uid):\n+        path = pathlib.Path(f\"./drive/{user_uid}/snek/templates\")\n+        if not path.exists():\n+            return None\n+        return path\n+\n+    async def get_home_folder(self, user_uid):\n+        folder = pathlib.Path(f\"./drive/{user_uid}\")\n+        if not folder.exists():\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n+        return folder\n+\n+    async def register(self, email, username, password):\n+        if await self.exists(username=username):\n+            raise Exception(\"User already exists.\")\n+        model = await self.new()\n+        model[\"nick\"] = username\n+        model[\"color\"] = await self.services.util.random_light_hex_color()\n+        model.email.value = email\n+        model.username.value = username\n+        model.password.value = await security.hash(password)\n+        if await self.save(model):\n+            if model:\n+                channel = await self.services.channel.ensure_public_channel(\n+                    model[\"uid\"]\n+                )\n+                if not channel:\n+                    raise Exception(\"Failed to create public channel.\")\n+            return model\n+        raise Exception(f\"Failed to create user: {model.errors}.\")\ndiff --git a/src/snek/service/user_property.py b/src/snek/service/user_property.py\nindex da9136a..4d11fa8 100644\n--- a/src/snek/service/user_property.py\n+++ b/src/snek/service/user_property.py\n@@ -1,15 +1,35 @@\n-_A='user_property'\n import json\n+\n from snek.system.service import BaseService\n+\n+\n class UserPropertyService(BaseService):\n-\tmapper_name=_A\n-\tasync def set(C,user_uid,name,value):A='name';B='user_uid';C.mapper.db[_A].upsert({B:user_uid,A:name,'value':json.dumps(value,default=str)},[B,A])\n-\tasync def get(B,user_uid,name):\n-\t\ttry:return json.loads((await super().get(user_uid=user_uid,name=name))['value'])\n-\t\texcept Exception as A:print(A);return\n-\tasync def search(C,query,**D):\n-\t\tA=query;A=A.strip().lower()\n-\t\tif not A:raise[]\n-\t\tB=[]\n-\t\tasync for E in C.find(name={'ilike':'%'+A+'%'},**D):B.append(E)\n-\t\treturn B\n\\ No newline at end of file\n+    mapper_name = \"user_property\"\n+\n+    async def set(self, user_uid, name, value):\n+        self.mapper.db[\"user_property\"].upsert(\n+            {\n+                \"user_uid\": user_uid,\n+                \"name\": name,\n+                \"value\": json.dumps(value, default=str),\n+            },\n+            [\"user_uid\", \"name\"],\n+        )\n+\n+    async def get(self, user_uid, name):\n+        try:\n+            return json.loads(\n+                (await super().get(user_uid=user_uid, name=name))[\"value\"]\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            return None\n+\n+    async def search(self, query, **kwargs):\n+        query = query.strip().lower()\n+        if not query:\n+            raise []\n+        results = []\n+        async for result in self.find(name={\"ilike\": \"%\" + query + \"%\"}, **kwargs):\n+            results.append(result)\n+        return results\ndiff --git a/src/snek/service/util.py b/src/snek/service/util.py\nindex 73dbec3..b620d9c 100644\n--- a/src/snek/service/util.py\n+++ b/src/snek/service/util.py\n@@ -1,4 +1,14 @@\n import random\n+\n from snek.system.service import BaseService\n+\n+\n class UtilService(BaseService):\n\\ No newline at end of file\n+\n+    async def random_light_hex_color(self):\n+\n+        r = random.randint(128, 255)\n+        g = random.randint(128, 255)\n+        b = random.randint(128, 255)\n+\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 0f3a69f..f8bfeb7 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,207 +1,489 @@\n-_O='branches'\n-_N='message'\n-_M='author'\n-_L='Invalid JSON data'\n-_K='origin'\n-_J='Repository not found'\n-_I='main'\n-_H='repository'\n-_G='branch'\n-_F='.git'\n-_E=None\n-_D='user'\n-_C='repo_name'\n-_B='username'\n-_A='repository_path'\n-import os,aiohttp\n+import os\n+import aiohttp\n from aiohttp import web\n-import git,shutil,json,tempfile,asyncio,logging,base64,pathlib\n-logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n-logger=logging.getLogger('git_server')\n+import git\n+import shutil\n+import json\n+import tempfile\n+import asyncio\n+import logging\n+import base64\n+import pathlib\n+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')\n+logger = logging.getLogger('git_server')\n+\n class GitApplication(web.Application):\n-\tdef __init__(A,parent=_E):B='/branches/{repo_name}';A.parent=parent;super().__init__(client_max_size=5368709120);A.REPO_DIR='drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545';A.USERS={'x':'x','bob':'bobpass'};A.add_routes([web.post('/create/{repo_name}',A.create_repository),web.delete('/delete/{repo_name}',A.delete_repository),web.get('/clone/{repo_name}',A.clone_repository),web.post('/push/{repo_name}',A.push_repository),web.post('/pull/{repo_name}',A.pull_repository),web.get('/status/{repo_name}',A.status_repository),web.get('/list',A.list_repositories),web.get(B,A.list_branches),web.post(B,A.create_branch),web.get('/log/{repo_name}',A.commit_log),web.get('/file/{repo_name}/{file_path:.*}',A.file_content),web.get('/{path:.+}/info/refs',A.git_smart_http),web.post('/{path:.+}/git-upload-pack',A.git_smart_http),web.post('/{path:.+}/git-receive-pack',A.git_smart_http),web.get('/{repo_name}.git/info/refs',A.git_smart_http),web.post('/{repo_name}.git/git-upload-pack',A.git_smart_http),web.post('/{repo_name}.git/git-receive-pack',A.git_smart_http)])\n-\tasync def check_basic_auth(B,request):\n-\t\tC='Basic ';A=request;D=A.headers.get('Authorization','')\n-\t\tif not D.startswith(C):return _E,_E\n-\t\tE=D.split(C)[1];F=base64.b64decode(E).decode();G,H=F.split(':',1);A[_D]=await B.parent.services.user.authenticate(username=G,password=H)\n-\t\tif not A[_D]:return _E,_E\n-\t\tA[_A]=await B.parent.services.user.get_repository_path(A[_D]['uid']);return A[_D][_B],A[_A]\n-\t@staticmethod\n-\tdef require_auth(handler):\n-\t\tasync def A(self,request,*D,**E):\n-\t\t\tA=request;B,C=await self.check_basic_auth(A)\n-\t\t\tif not B or not C:return web.Response(status=401,headers={'WWW-Authenticate':'Basic'},text='Authentication required')\n-\t\t\tA[_B]=B;A[_A]=C;return await handler(self,A,*D,**E)\n-\t\treturn A\n-\tdef repo_path(A,repository_path,repo_name):return repository_path.joinpath(repo_name+_F)\n-\tdef check_repo_exists(A,repository_path,repo_name):\n-\t\tB=A.repo_path(repository_path,repo_name)\n-\t\tif not os.path.exists(B):return web.Response(text=_J,status=404)\n-\t@require_auth\n-\tasync def create_repository(self,request):\n-\t\tB=request;E=B[_B];A=B.match_info[_C];F=B[_A]\n-\t\tif not A or'/'in A or'..'in A:return web.Response(text='Invalid repository name',status=400)\n-\t\tC=self.repo_path(F,A)\n-\t\tif os.path.exists(C):return web.Response(text='Repository already exists',status=400)\n-\t\ttry:git.Repo.init(C,bare=True);logger.info(f\"Created repository: {A} for user {E}\");return web.Response(text=f\"Created repository {A}\")\n-\t\texcept Exception as D:logger.error(f\"Error creating repository {A}: {str(D)}\");return web.Response(text=f\"Error creating repository: {str(D)}\",status=500)\n-\t@require_auth\n-\tasync def delete_repository(self,request):\n-\t\tB=request;F=B[_B];A=B.match_info[_C];C=B[_A];D=self.check_repo_exists(C,A)\n-\t\tif D:return D\n-\t\ttry:shutil.rmtree(self.repo_path(C,A));logger.info(f\"Deleted repository: {A} for user {F}\");return web.Response(text=f\"Deleted repository {A}\")\n-\t\texcept Exception as E:logger.error(f\"Error deleting repository {A}: {str(E)}\");return web.Response(text=f\"Error deleting repository: {str(E)}\",status=500)\n-\t@require_auth\n-\tasync def clone_repository(self,request):\n-\t\tA=request;H=A[_B];B=A.match_info[_C];E=A[_A];C=self.check_repo_exists(E,B)\n-\t\tif C:return C\n-\t@require_auth\n-\tasync def push_repository(self,request):\n-\t\tB=request;L=B[_B];C=B.match_info[_C];E=B[_A];F=self.check_repo_exists(E,C)\n-\t\tif F:return F\n-\t\ttry:D=await B.json()\n-\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n-\t\tM=D.get('commit_message','Update from server');G=D.get(_G,_I);H=D.get('changes',[])\n-\t\tif not H:return web.Response(text='No changes provided',status=400)\n-\t\twith tempfile.TemporaryDirectory()as I:\n-\t\t\tA=git.Repo.clone_from(self.repo_path(E,C),I)\n-\t\t\tfor J in H:\n-\t\t\t\tK=os.path.join(I,J.get('file',''));N=J.get('content','');os.makedirs(os.path.dirname(K),exist_ok=True)\n-\t\t\t\twith open(K,'w')as O:O.write(N)\n-\t\t\tA.git.add(A=True)\n-\t\t\tif not A.config_reader().has_section(_D):A.config_writer().set_value(_D,'name','Git Server').release();A.config_writer().set_value(_D,'email','git@server.local').release()\n-\t\t\tA.index.commit(M);P=A.remote(_K);P.push(refspec=f\"{G}:{G}\")\n-\t\tlogger.info(f\"Pushed to repository: {C} for user {L}\");return web.Response(text=f\"Successfully pushed changes to {C}\")\n-\t@require_auth\n-\tasync def pull_repository(self,request):\n-\t\tC=request;K=C[_B];A=C.match_info[_C];H=C[_A];I=self.check_repo_exists(H,A)\n-\t\tif I:return I\n-\t\ttry:E=await C.json()\n-\t\texcept json.JSONDecodeError:E={}\n-\t\tB=E.get('remote_url');L=E.get(_G,_I)\n-\t\tif not B:return web.Response(text='Remote URL is required',status=400)\n-\t\twith tempfile.TemporaryDirectory()as M:\n-\t\t\ttry:\n-\t\t\t\tD=git.Repo.clone_from(self.repo_path(H,A),M);F='pull_source'\n-\t\t\t\ttry:G=D.create_remote(F,B)\n-\t\t\t\texcept git.GitCommandError:G=D.remote(F);G.set_url(B)\n-\t\t\t\tG.fetch();D.git.merge(f\"{F}/{L}\");N=D.remote(_K);N.push();logger.info(f\"Pulled to repository {A} from {B} for user {K}\");return web.Response(text=f\"Successfully pulled changes from {B} to {A}\")\n-\t\t\texcept Exception as J:logger.error(f\"Error pulling to {A}: {str(J)}\");return web.Response(text=f\"Error pulling changes: {str(J)}\",status=500)\n-\t@require_auth\n-\tasync def status_repository(self,request):\n-\t\tC=request;S=C[_B];B=C.match_info[_C];F=C[_A];G=self.check_repo_exists(F,B)\n-\t\tif G:return G\n-\t\twith tempfile.TemporaryDirectory()as D:\n-\t\t\ttry:\n-\t\t\t\tE=git.Repo.clone_from(self.repo_path(F,B),D);L=[A.name for A in E.branches];M=E.active_branch.name;H=[]\n-\t\t\t\tfor A in list(E.iter_commits(max_count=5)):H.append({'id':A.hexsha,_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message})\n-\t\t\t\tI=[]\n-\t\t\t\tfor(J,T,N)in os.walk(D):\n-\t\t\t\t\tif _F in J:continue\n-\t\t\t\t\tfor O in N:P=os.path.join(J,O);Q=os.path.relpath(P,D);I.append(Q)\n-\t\t\t\tR={_H:B,_O:L,'active_branch':M,'recent_commits':H,'files':I};return web.json_response(R)\n-\t\t\texcept Exception as K:logger.error(f\"Error getting status for {B}: {str(K)}\");return web.Response(text=f\"Error getting repository status: {str(K)}\",status=500)\n-\t@require_auth\n-\tasync def list_repositories(self,request):\n-\t\tD=request;G=D[_B]\n-\t\ttry:\n-\t\t\tA=[];B=self.REPO_DIR\n-\t\t\tif os.path.exists(B):\n-\t\t\t\tfor C in os.listdir(B):\n-\t\t\t\t\tF=os.path.join(B,C)\n-\t\t\t\t\tif os.path.isdir(F)and C.endswith(_F):A.append(C[:-4])\n-\t\t\tif D.query.get('format')=='json':return web.json_response({'repositories':A})\n-\t\t\telse:return web.Response(text='\\n'.join(A)if A else'No repositories found')\n-\t\texcept Exception as E:logger.error(f\"Error listing repositories: {str(E)}\");return web.Response(text=f\"Error listing repositories: {str(E)}\",status=500)\n-\t@require_auth\n-\tasync def list_branches(self,request):\n-\t\tA=request;H=A[_B];B=A.match_info[_C];C=A[_A];D=self.check_repo_exists(C,B)\n-\t\tif D:return D\n-\t\twith tempfile.TemporaryDirectory()as E:F=git.Repo.clone_from(self.repo_path(C,B),E);G=[A.name for A in F.branches];return web.json_response({_O:G})\n-\t@require_auth\n-\tasync def create_branch(self,request):\n-\t\tB=request;I=B[_B];C=B.match_info[_C];D=B[_A];E=self.check_repo_exists(D,C)\n-\t\tif E:return E\n-\t\ttry:F=await B.json()\n-\t\texcept json.JSONDecodeError:return web.Response(text=_L,status=400)\n-\t\tA=F.get('branch_name');J=F.get('start_point','HEAD')\n-\t\tif not A:return web.Response(text='Branch name is required',status=400)\n-\t\twith tempfile.TemporaryDirectory()as K:\n-\t\t\ttry:G=git.Repo.clone_from(self.repo_path(D,C),K);G.git.branch(A,J);G.git.push(_K,A);logger.info(f\"Created branch {A} in repository {C} for user {I}\");return web.Response(text=f\"Created branch {A}\")\n-\t\t\texcept Exception as H:logger.error(f\"Error creating branch {A} in {C}: {str(H)}\");return web.Response(text=f\"Error creating branch: {str(H)}\",status=500)\n-\t@require_auth\n-\tasync def commit_log(self,request):\n-\t\tB=request;L=B[_B];C=B.match_info[_C];F=B[_A];G=self.check_repo_exists(F,C)\n-\t\tif G:return G\n-\t\ttry:I=int(B.query.get('limit',10));H=B.query.get(_G,_I)\n-\t\texcept ValueError:return web.Response(text='Invalid limit parameter',status=400)\n-\t\twith tempfile.TemporaryDirectory()as J:\n-\t\t\ttry:\n-\t\t\t\tK=git.Repo.clone_from(self.repo_path(F,C),J);E=[]\n-\t\t\t\ttry:\n-\t\t\t\t\tfor A in list(K.iter_commits(H,max_count=I)):E.append({'id':A.hexsha,'short_id':A.hexsha[:7],_M:f\"{A.author.name} <{A.author.email}>\",'date':A.committed_datetime.isoformat(),_N:A.message.strip()})\n-\t\t\t\texcept git.GitCommandError as D:\n-\t\t\t\t\tif'unknown revision or path'in str(D):E=[]\n-\t\t\t\t\telse:raise\n-\t\t\t\treturn web.json_response({_H:C,_G:H,'commits':E})\n-\t\t\texcept Exception as D:logger.error(f\"Error getting commit log for {C}: {str(D)}\");return web.Response(text=f\"Error getting commit log: {str(D)}\",status=500)\n-\t@require_auth\n-\tasync def file_content(self,request):\n-\t\tA=request;N=A[_B];B=A.match_info[_C];C=A.match_info.get('file_path','');E=A.query.get(_G,_I);F=A[_A];G=self.check_repo_exists(F,B)\n-\t\tif G:return G\n-\t\twith tempfile.TemporaryDirectory()as H:\n-\t\t\ttry:\n-\t\t\t\tJ=git.Repo.clone_from(self.repo_path(F,B),H)\n-\t\t\t\ttry:J.git.checkout(E)\n-\t\t\t\texcept git.GitCommandError:return web.Response(text=f\"Branch '{E}' not found\",status=404)\n-\t\t\t\tD=os.path.join(H,C)\n-\t\t\t\tif not os.path.exists(D):return web.Response(text=f\"File '{C}' not found\",status=404)\n-\t\t\t\tif os.path.isdir(D):K=os.listdir(D);return web.json_response({_H:B,'path':C,'type':'directory','contents':K})\n-\t\t\t\telse:\n-\t\t\t\t\ttry:\n-\t\t\t\t\t\twith open(D,'r')as L:M=L.read()\n-\t\t\t\t\t\treturn web.Response(text=M)\n-\t\t\t\t\texcept UnicodeDecodeError:return web.Response(text=f\"Cannot display binary file content for '{C}'\",status=400)\n-\t\t\texcept Exception as I:logger.error(f\"Error getting file content from {B}: {str(I)}\");return web.Response(text=f\"Error getting file content: {str(I)}\",status=500)\n-\t@require_auth\n-\tasync def git_smart_http(self,request):\n-\t\tB='POST';G='git-receive-pack';H='git-upload-pack';I='Content-Type';J='--stateless-rpc';D='/git-receive-pack';E='/git-upload-pack';F='/info/refs';A=request;P=A[_B];N=A[_A];C=A.path\n-\t\tasync def K():\n-\t\t\tB=C.lstrip('/')\n-\t\t\tif B.endswith(F):A=B[:-len(F)]\n-\t\t\telif B.endswith(E):A=B[:-len(E)]\n-\t\t\telif B.endswith(D):A=B[:-len(D)]\n-\t\t\telse:A=B\n-\t\t\tif A.endswith(_F):A=A[:-4]\n-\t\t\tA=A[4:];G=N.joinpath(A+_F);logger.info(f\"Resolved repo path: {G}\");return G\n-\t\tasync def O(service):\n-\t\t\tC=service;D=await K();logger.info(f\"handle_info_refs: {D}\")\n-\t\t\tif not os.path.exists(D):return web.Response(text=_J,status=404)\n-\t\t\tL=[C,J,'--advertise-refs',str(D)]\n-\t\t\ttry:\n-\t\t\t\tE=await asyncio.create_subprocess_exec(*L,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);M,F=await E.communicate()\n-\t\t\t\tif E.returncode!=0:logger.error(f\"Git command failed: {F.decode()}\");return web.Response(text=f\"Git error: {F.decode()}\",status=500)\n-\t\t\texcept Exception as H:logger.error(f\"Error handling info/refs: {str(H)}\");return web.Response(text=f\"Server error: {str(H)}\",status=500)\n-\t\tasync def L(service):\n-\t\t\tB=service;C=await K();logger.info(f\"handle_service_rpc: {C}\")\n-\t\t\tif not os.path.exists(C):return web.Response(text=_J,status=404)\n-\t\t\tif not A.headers.get(I)==f\"application/x-{B}-request\":return web.Response(text='Invalid Content-Type',status=403)\n-\t\t\tG=await A.read();H=[B,J,str(C)]\n-\t\t\ttry:\n-\t\t\t\tD=await asyncio.create_subprocess_exec(*H,stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE);L,E=await D.communicate(input=G)\n-\t\t\t\tif D.returncode!=0:logger.error(f\"Git command failed: {E.decode()}\");return web.Response(text=f\"Git error: {E.decode()}\",status=500)\n-\t\t\t\treturn web.Response(body=L,content_type=f\"application/x-{B}-result\")\n-\t\t\texcept Exception as F:logger.error(f\"Error handling service RPC: {str(F)}\");return web.Response(text=f\"Server error: {str(F)}\",status=500)\n-\t\tif A.method=='GET'and C.endswith(F):\n-\t\t\tM=A.query.get('service')\n-\t\t\tif M in(H,G):return await O(M)\n-\t\t\telse:return web.Response(text='Smart HTTP requires service parameter',status=400)\n-\t\telif A.method==B and E in C:return await L(H)\n-\t\telif A.method==B and D in C:return await L(G)\n-\t\treturn web.Response(text='Not found',status=404)\n-if __name__=='__main__':\n-\ttry:import uvloop;asyncio.set_event_loop_policy(uvloop.EventLoopPolicy());logger.info('Using uvloop for improved performance')\n-\texcept ImportError:logger.info('uvloop not available, using standard event loop')\n-\tapp=GitApplication();logger.info('Starting Git server on port 8080');web.run_app(app,port=8080)\n\\ No newline at end of file\n+    def __init__(self, parent=None):\n+        self.parent = parent\n+        super().__init__(client_max_size=1024*1024*1024*5)\n+        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n+        self.USERS = {\n+            'x': 'x',\n+            'bob': 'bobpass',\n+        }\n+        self.add_routes([\n+            web.post('/create/{repo_name}', self.create_repository),\n+            web.delete('/delete/{repo_name}', self.delete_repository),\n+            web.get('/clone/{repo_name}', self.clone_repository),\n+            web.post('/push/{repo_name}', self.push_repository),\n+            web.post('/pull/{repo_name}', self.pull_repository),\n+            web.get('/status/{repo_name}', self.status_repository),\n+            web.get('/list', self.list_repositories),\n+            web.get('/branches/{repo_name}', self.list_branches),\n+            web.post('/branches/{repo_name}', self.create_branch),\n+            web.get('/log/{repo_name}', self.commit_log),\n+            web.get('/file/{repo_name}/{file_path:.*}', self.file_content),\n+            web.get('/{path:.+}/info/refs', self.git_smart_http),\n+            web.post('/{path:.+}/git-upload-pack', self.git_smart_http),\n+            web.post('/{path:.+}/git-receive-pack', self.git_smart_http),\n+            web.get('/{repo_name}.git/info/refs', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-upload-pack', self.git_smart_http),\n+            web.post('/{repo_name}.git/git-receive-pack', self.git_smart_http),\n+        ])\n+\n+\n+    async def check_basic_auth(self, request):\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return None,None\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.parent.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        if not request[\"user\"]:\n+            return None,None\n+        request[\"repository_path\"] = await self.parent.services.user.get_repository_path(\n+            request[\"user\"][\"uid\"]\n+        )\n+\n+        return request[\"user\"]['username'],request[\"repository_path\"]\n+\n+\n+    @staticmethod\n+    def require_auth(handler):\n+        async def wrapped(self, request, *args, **kwargs):\n+            username, repository_path = await self.check_basic_auth(request)\n+            if not username or not repository_path:\n+                return web.Response(status=401, headers={'WWW-Authenticate': 'Basic'}, text='Authentication required')\n+            request['username'] = username\n+            request['repository_path'] = repository_path\n+            return await handler(self, request, *args, **kwargs)\n+        return wrapped\n+\n+    def repo_path(self, repository_path, repo_name):\n+        return repository_path.joinpath(repo_name + '.git')\n+\n+    def check_repo_exists(self, repository_path, repo_name):\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if not os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository not found\", status=404)\n+        return None\n+\n+    @require_auth\n+    async def create_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        if not repo_name or '/' in repo_name or '..' in repo_name:\n+            return web.Response(text=\"Invalid repository name\", status=400)\n+        repo_dir = self.repo_path(repository_path, repo_name)\n+        if os.path.exists(repo_dir):\n+            return web.Response(text=\"Repository already exists\", status=400)\n+        try:\n+            git.Repo.init(repo_dir, bare=True)\n+            logger.info(f\"Created repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Created repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error creating repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error creating repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def delete_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            shutil.rmtree(self.repo_path(repository_path, repo_name))\n+            logger.info(f\"Deleted repository: {repo_name} for user {username}\")\n+            return web.Response(text=f\"Deleted repository {repo_name}\")\n+        except Exception as e:\n+            logger.error(f\"Error deleting repository {repo_name}: {str(e)}\")\n+            return web.Response(text=f\"Error deleting repository: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def clone_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        host = request.host\n+        response_data = {\n+            \"repository\": repo_name,\n+            \"clone_command\": f\"git clone {clone_url}\",\n+            \"clone_url\": clone_url\n+        }\n+        return web.json_response(response_data)\n+\n+    @require_auth\n+    async def push_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        commit_message = data.get('commit_message', 'Update from server')\n+        branch = data.get('branch', 'main')\n+        changes = data.get('changes', [])\n+        if not changes:\n+            return web.Response(text=\"No changes provided\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            for change in changes:\n+                file_path = os.path.join(temp_dir, change.get('file', ''))\n+                content = change.get('content', '')\n+                os.makedirs(os.path.dirname(file_path), exist_ok=True)\n+                with open(file_path, 'w') as f:\n+                    f.write(content)\n+            temp_repo.git.add(A=True)\n+            if not temp_repo.config_reader().has_section('user'):\n+                temp_repo.config_writer().set_value(\"user\", \"name\", \"Git Server\").release()\n+                temp_repo.config_writer().set_value(\"user\", \"email\", \"git@server.local\").release()\n+            temp_repo.index.commit(commit_message)\n+            origin = temp_repo.remote('origin')\n+            origin.push(refspec=f\"{branch}:{branch}\")\n+        logger.info(f\"Pushed to repository: {repo_name} for user {username}\")\n+        return web.Response(text=f\"Successfully pushed changes to {repo_name}\")\n+\n+    @require_auth\n+    async def pull_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            data = {}\n+        remote_url = data.get('remote_url')\n+        branch = data.get('branch', 'main')\n+        if not remote_url:\n+            return web.Response(text=\"Remote URL is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                local_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                remote_name = \"pull_source\"\n+                try:\n+                    remote = local_repo.create_remote(remote_name, remote_url)\n+                except git.GitCommandError:\n+                    remote = local_repo.remote(remote_name)\n+                    remote.set_url(remote_url)\n+                remote.fetch()\n+                local_repo.git.merge(f\"{remote_name}/{branch}\")\n+                origin = local_repo.remote('origin')\n+                origin.push()\n+                logger.info(f\"Pulled to repository {repo_name} from {remote_url} for user {username}\")\n+                return web.Response(text=f\"Successfully pulled changes from {remote_url} to {repo_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error pulling to {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error pulling changes: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def status_repository(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                branches = [b.name for b in temp_repo.branches]\n+                active_branch = temp_repo.active_branch.name\n+                commits = []\n+                for commit in list(temp_repo.iter_commits(max_count=5)):\n+                    commits.append({\n+                        \"id\": commit.hexsha,\n+                        \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                        \"date\": commit.committed_datetime.isoformat(),\n+                        \"message\": commit.message\n+                    })\n+                files = []\n+                for root, dirs, filenames in os.walk(temp_dir):\n+                    if '.git' in root:\n+                        continue\n+                    for filename in filenames:\n+                        full_path = os.path.join(root, filename)\n+                        rel_path = os.path.relpath(full_path, temp_dir)\n+                        files.append(rel_path)\n+                status_info = {\n+                    \"repository\": repo_name,\n+                    \"branches\": branches,\n+                    \"active_branch\": active_branch,\n+                    \"recent_commits\": commits,\n+                    \"files\": files\n+                }\n+                return web.json_response(status_info)\n+            except Exception as e:\n+                logger.error(f\"Error getting status for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting repository status: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_repositories(self, request):\n+        username = request['username']\n+        try:\n+            repos = []\n+            user_dir = self.REPO_DIR\n+            if os.path.exists(user_dir):\n+                for item in os.listdir(user_dir):\n+                    item_path = os.path.join(user_dir, item)\n+                    if os.path.isdir(item_path) and item.endswith('.git'):\n+                        repos.append(item[:-4])\n+            if request.query.get('format') == 'json':\n+                return web.json_response({\"repositories\": repos})\n+            else:\n+                return web.Response(text=\"\\n\".join(repos) if repos else \"No repositories found\")\n+        except Exception as e:\n+            logger.error(f\"Error listing repositories: {str(e)}\")\n+            return web.Response(text=f\"Error listing repositories: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def list_branches(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+            branches = [b.name for b in temp_repo.branches]\n+            return web.json_response({\"branches\": branches})\n+\n+    @require_auth\n+    async def create_branch(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            data = await request.json()\n+        except json.JSONDecodeError:\n+            return web.Response(text=\"Invalid JSON data\", status=400)\n+        branch_name = data.get('branch_name')\n+        start_point = data.get('start_point', 'HEAD')\n+        if not branch_name:\n+            return web.Response(text=\"Branch name is required\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                temp_repo.git.branch(branch_name, start_point)\n+                temp_repo.git.push('origin', branch_name)\n+                logger.info(f\"Created branch {branch_name} in repository {repo_name} for user {username}\")\n+                return web.Response(text=f\"Created branch {branch_name}\")\n+            except Exception as e:\n+                logger.error(f\"Error creating branch {branch_name} in {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error creating branch: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def commit_log(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        try:\n+            limit = int(request.query.get('limit', 10))\n+            branch = request.query.get('branch', 'main')\n+        except ValueError:\n+            return web.Response(text=\"Invalid limit parameter\", status=400)\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                commits = []\n+                try:\n+                    for commit in list(temp_repo.iter_commits(branch, max_count=limit)):\n+                        commits.append({\n+                            \"id\": commit.hexsha,\n+                            \"short_id\": commit.hexsha[:7],\n+                            \"author\": f\"{commit.author.name} <{commit.author.email}>\",\n+                            \"date\": commit.committed_datetime.isoformat(),\n+                            \"message\": commit.message.strip()\n+                        })\n+                except git.GitCommandError as e:\n+                    if \"unknown revision or path\" in str(e):\n+                        commits = []\n+                    else:\n+                        raise\n+                return web.json_response({\n+                    \"repository\": repo_name,\n+                    \"branch\": branch,\n+                    \"commits\": commits\n+                })\n+            except Exception as e:\n+                logger.error(f\"Error getting commit log for {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting commit log: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def file_content(self, request):\n+        username = request['username']\n+        repo_name = request.match_info['repo_name']\n+        file_path = request.match_info.get('file_path', '')\n+        branch = request.query.get('branch', 'main')\n+        repository_path = request['repository_path']\n+        error_response = self.check_repo_exists(repository_path, repo_name)\n+        if error_response:\n+            return error_response\n+        with tempfile.TemporaryDirectory() as temp_dir:\n+            try:\n+                temp_repo = git.Repo.clone_from(self.repo_path(repository_path, repo_name), temp_dir)\n+                try:\n+                    temp_repo.git.checkout(branch)\n+                except git.GitCommandError:\n+                    return web.Response(text=f\"Branch '{branch}' not found\", status=404)\n+                file_full_path = os.path.join(temp_dir, file_path)\n+                if not os.path.exists(file_full_path):\n+                    return web.Response(text=f\"File '{file_path}' not found\", status=404)\n+                if os.path.isdir(file_full_path):\n+                    files = os.listdir(file_full_path)\n+                    return web.json_response({\n+                        \"repository\": repo_name,\n+                        \"path\": file_path,\n+                        \"type\": \"directory\",\n+                        \"contents\": files\n+                    })\n+                else:\n+                    try:\n+                        with open(file_full_path, 'r') as f:\n+                            content = f.read()\n+                        return web.Response(text=content)\n+                    except UnicodeDecodeError:\n+                        return web.Response(text=f\"Cannot display binary file content for '{file_path}'\", status=400)\n+            except Exception as e:\n+                logger.error(f\"Error getting file content from {repo_name}: {str(e)}\")\n+                return web.Response(text=f\"Error getting file content: {str(e)}\", status=500)\n+\n+    @require_auth\n+    async def git_smart_http(self, request):\n+        username = request['username']\n+        repository_path = request['repository_path']\n+        path = request.path\n+        async def get_repository_path():\n+            req_path = path.lstrip('/')\n+            if req_path.endswith('/info/refs'):\n+                repo_name = req_path[:-len('/info/refs')]\n+            elif req_path.endswith('/git-upload-pack'):\n+                repo_name = req_path[:-len('/git-upload-pack')]\n+            elif req_path.endswith('/git-receive-pack'):\n+                repo_name = req_path[:-len('/git-receive-pack')]\n+            else:\n+                repo_name = req_path\n+            if repo_name.endswith('.git'):\n+                repo_name = repo_name[:-4]\n+            repo_name = repo_name[4:]\n+            repo_dir = repository_path.joinpath(repo_name + \".git\")\n+            logger.info(f\"Resolved repo path: {repo_dir}\")\n+            return repo_dir \n+        async def handle_info_refs(service):\n+            repo_path = await get_repository_path()\n+        \n+            logger.info(f\"handle_info_refs: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            cmd = [service, '--stateless-rpc', '--advertise-refs', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate()\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason='OK',\n+                    headers={\n+                        'Content-Type': f'application/x-{service}-advertisement',\n+                        'Cache-Control': 'no-cache'\n+                    }\n+                )\n+                await response.prepare(request)\n+                length = len(packet) + 4\n+                header = f\"{length:04x}\"\n+                await response.write(f\"{header}{packet}0000\".encode())\n+                await response.write(stdout)\n+                return response\n+            except Exception as e:\n+                logger.error(f\"Error handling info/refs: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        async def handle_service_rpc(service):\n+            repo_path = await get_repository_path()\n+            logger.info(f\"handle_service_rpc: {repo_path}\")\n+            if not os.path.exists(repo_path):\n+                return web.Response(text=\"Repository not found\", status=404)\n+            if not request.headers.get('Content-Type') == f'application/x-{service}-request':\n+                return web.Response(text=\"Invalid Content-Type\", status=403)\n+            body = await request.read()\n+            cmd = [service, '--stateless-rpc', str(repo_path)]\n+            try:\n+                process = await asyncio.create_subprocess_exec(\n+                    *cmd,\n+                    stdin=asyncio.subprocess.PIPE,\n+                    stdout=asyncio.subprocess.PIPE,\n+                    stderr=asyncio.subprocess.PIPE\n+                )\n+                stdout, stderr = await process.communicate(input=body)\n+                if process.returncode != 0:\n+                    logger.error(f\"Git command failed: {stderr.decode()}\")\n+                    return web.Response(text=f\"Git error: {stderr.decode()}\", status=500)\n+                return web.Response(\n+                    body=stdout,\n+                    content_type=f'application/x-{service}-result'\n+                )\n+            except Exception as e:\n+                logger.error(f\"Error handling service RPC: {str(e)}\")\n+                return web.Response(text=f\"Server error: {str(e)}\", status=500)\n+        if request.method == 'GET' and path.endswith('/info/refs'):\n+            service = request.query.get('service')\n+            if service in ('git-upload-pack', 'git-receive-pack'):\n+                return await handle_info_refs(service)\n+            else:\n+                return web.Response(text=\"Smart HTTP requires service parameter\", status=400)\n+        elif request.method == 'POST' and '/git-upload-pack' in path:\n+            return await handle_service_rpc('git-upload-pack')\n+        elif request.method == 'POST' and '/git-receive-pack' in path:\n+            return await handle_service_rpc('git-receive-pack')\n+        return web.Response(text=\"Not found\", status=404)\n+\n+if __name__ == '__main__':\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+        logger.info(\"Using uvloop for improved performance\")\n+    except ImportError:\n+        logger.info(\"uvloop not available, using standard event loop\")\n+    app = GitApplication()\n+    logger.info(\"Starting Git server on port 8080\")\n+    web.run_app(app, port=8080)\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex 57e90a3..eed888a 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -1,67 +1,144 @@\n-_C='delete'\n-_B='set'\n-_A='get'\n-import functools,json\n+import functools\n+import json\n+\n from snek.system import security\n-cache=functools.cache\n-CACHE_MAX_ITEMS_DEFAULT=5000\n+\n+cache = functools.cache\n+\n+CACHE_MAX_ITEMS_DEFAULT = 5000\n+\n+\n class Cache:\n-\tdef __init__(A,app,max_items=CACHE_MAX_ITEMS_DEFAULT):A.app=app;A.cache={};A.max_items=max_items;A.stats={};A.lru=[];A.version=15505\n-\tasync def get(A,args):\n-\t\tB=args;await A.update_stat(B,_A)\n-\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\texcept:return\n-\t\tA.lru.insert(0,B)\n-\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n-\t\treturn A.cache[B]\n-\tasync def get_stats(A):\n-\t\tC=[]\n-\t\tfor B in A.lru:C.append({'key':B,_B:A.stats[B][_B],_A:A.stats[B][_A],_C:A.stats[B][_C],'value':str(A.serialize(A.cache[B].record))})\n-\t\treturn C\n-\tdef serialize(C,obj):B=None;A=obj.copy();A.pop('created_at',B);A.pop('deleted_at',B);A.pop('email',B);A.pop('password',B);return A\n-\tasync def update_stat(A,key,action):\n-\t\tC=action;B=key\n-\t\tif B not in A.stats:A.stats[B]={_B:0,_A:0,_C:0}\n-\t\tA.stats[B][C]=A.stats[B][C]+1\n-\tdef json_default(B,value):\n-\t\tA=value\n-\t\ttry:return json.dumps(A.__dict__,default=str)\n-\t\texcept:return str(A)\n-\tasync def create_cache_key(A,args,kwargs):return await security.hash(json.dumps({'args':args,'kwargs':kwargs},sort_keys=True,default=A.json_default))\n-\tasync def set(A,args,result):\n-\t\tB=args;C=B not in A.cache;A.cache[B]=result;await A.update_stat(B,_B)\n-\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\texcept(ValueError,IndexError):pass\n-\t\tA.lru.insert(0,B)\n-\t\twhile len(A.lru)>A.max_items:A.cache.pop(A.lru[-1]);A.lru.pop()\n-\t\tif C:A.version+=1\n-\tasync def delete(A,args):\n-\t\tB=args;await A.update_stat(B,_C)\n-\t\tif B in A.cache:\n-\t\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\t\texcept IndexError:pass\n-\t\t\tdel A.cache[B]\n-\tdef async_cache(A,func):\n-\t\t@functools.wraps(func)\n-\t\tasync def B(*B,**C):\n-\t\t\tD=await A.create_cache_key(B,C);E=await A.get(D)\n-\t\t\tif E:return E\n-\t\t\tF=await func(*B,**C);await A.set(D,F);return F\n-\t\treturn B\n-\tdef async_delete_cache(A,func):\n-\t\t@functools.wraps(func)\n-\t\tasync def B(*C,**D):\n-\t\t\tB=await A.create_cache_key(C,D)\n-\t\t\tif B in A.cache:\n-\t\t\t\ttry:A.lru.pop(A.lru.index(B))\n-\t\t\t\texcept IndexError:pass\n-\t\t\t\tdel A.cache[B]\n-\t\t\treturn await func(*C,**D)\n-\t\treturn B\n+    def __init__(self, app, max_items=CACHE_MAX_ITEMS_DEFAULT):\n+        self.app = app\n+        self.cache = {}\n+        self.max_items = max_items\n+        self.stats = {}\n+        self.lru = []\n+        self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n+\n+    async def get(self, args):\n+        await self.update_stat(args, \"get\")\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except:\n+            return None\n+        self.lru.insert(0, args)\n+        while len(self.lru) > self.max_items:\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+        return self.cache[args]\n+\n+    async def get_stats(self):\n+        all_ = []\n+        for key in self.lru:\n+            all_.append(\n+                {\n+                    \"key\": key,\n+                    \"set\": self.stats[key][\"set\"],\n+                    \"get\": self.stats[key][\"get\"],\n+                    \"delete\": self.stats[key][\"delete\"],\n+                    \"value\": str(self.serialize(self.cache[key].record)),\n+                }\n+            )\n+        return all_\n+\n+    def serialize(self, obj):\n+        cpy = obj.copy()\n+        cpy.pop(\"created_at\", None)\n+        cpy.pop(\"deleted_at\", None)\n+        cpy.pop(\"email\", None)\n+        cpy.pop(\"password\", None)\n+        return cpy\n+\n+    async def update_stat(self, key, action):\n+        if key not in self.stats:\n+            self.stats[key] = {\"set\": 0, \"get\": 0, \"delete\": 0}\n+        self.stats[key][action] = self.stats[key][action] + 1\n+\n+    def json_default(self, value):\n+        try:\n+            return json.dumps(value.__dict__, default=str)\n+        except:\n+            return str(value)\n+\n+    async def create_cache_key(self, args, kwargs):\n+        return await security.hash(\n+            json.dumps(\n+                {\"args\": args, \"kwargs\": kwargs},\n+                sort_keys=True,\n+                default=self.json_default,\n+            )\n+        )\n+\n+    async def set(self, args, result):\n+        is_new = args not in self.cache\n+        self.cache[args] = result\n+        await self.update_stat(args, \"set\")\n+        try:\n+            self.lru.pop(self.lru.index(args))\n+        except (ValueError, IndexError):\n+            pass\n+        self.lru.insert(0, args)\n+\n+        while len(self.lru) > self.max_items:\n+            self.cache.pop(self.lru[-1])\n+            self.lru.pop()\n+\n+        if is_new:\n+            self.version += 1\n+\n+    async def delete(self, args):\n+        await self.update_stat(args, \"delete\")\n+        if args in self.cache:\n+            try:\n+                self.lru.pop(self.lru.index(args))\n+            except IndexError:\n+                pass\n+            del self.cache[args]\n+\n+    def async_cache(self, func):\n+        @functools.wraps(func)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n+            cached = await self.get(cache_key)\n+            if cached:\n+                return cached\n+            result = await func(*args, **kwargs)\n+            await self.set(cache_key, result)\n+            return result\n+\n+        return wrapper\n+\n+    def async_delete_cache(self, func):\n+        @functools.wraps(func)\n+        async def wrapper(*args, **kwargs):\n+            cache_key = await self.create_cache_key(args, kwargs)\n+            if cache_key in self.cache:\n+                try:\n+                    self.lru.pop(self.lru.index(cache_key))\n+                except IndexError:\n+                    pass\n+                del self.cache[cache_key]\n+            return await func(*args, **kwargs)\n+\n+        return wrapper\n+\n+\n def async_cache(func):\n-\tB={}\n-\t@functools.wraps(func)\n-\tasync def A(*A):\n-\t\tif A in B:return B[A]\n-\t\tC=await func(*A);B[A]=C;return C\n-\treturn A\n\\ No newline at end of file\n+    cache = {}\n+\n+    @functools.wraps(func)\n+    async def wrapper(*args):\n+        if args in cache:\n+            return cache[args]\n+        result = await func(*args)\n+        cache[args] = result\n+        return result\n+\n+    return wrapper\ndiff --git a/src/snek/system/form.py b/src/snek/system/form.py\nindex 0ec782b..f4cf2d3 100644\n--- a/src/snek/system/form.py\n+++ b/src/snek/system/form.py\n@@ -1,32 +1,120 @@\n-_B='fields'\n-_A=None\n+\n+\n+\n+\n from snek.system import model\n+\n+\n class HTMLElement(model.ModelField):\n-\tdef __init__(A,id=_A,tag='div',name=_A,html=_A,class_name=_A,text=_A,*B,**C):A.tag=tag;A.text=text;A.id=id;A.class_name=class_name or name;A.html=html;super().__init__(*B,name=name,**C)\n-\tasync def to_json(B):A=await super().to_json();A['text']=B.text;A['id']=B.id;A['html']=B.html;A['class_name']=B.class_name;A['tag']=B.tag;return A\n-class FormElement(HTMLElement):0\n+    def __init__(\n+        self,\n+        id=None,\n+        tag=\"div\",\n+        name=None,\n+        html=None,\n+        class_name=None,\n+        text=None,\n+        *args,\n+        **kwargs,\n+    ):\n+        self.tag = tag\n+        self.text = text\n+        self.id = id\n+        self.class_name = class_name or name\n+        self.html = html\n+        super().__init__(name=name, *args, **kwargs)\n+\n+    async def to_json(self):\n+        result = await super().to_json()\n+        result[\"text\"] = self.text\n+        result[\"id\"] = self.id\n+        result[\"html\"] = self.html\n+        result[\"class_name\"] = self.class_name\n+        result[\"tag\"] = self.tag\n+        return result\n+\n+\n+class FormElement(HTMLElement):\n+    pass\n+\n+\n class FormInputElement(FormElement):\n-\tdef __init__(A,type='text',place_holder=_A,*B,**C):super().__init__(*B,tag='input',**C);A.place_holder=place_holder;A.type=type\n-\tasync def to_json(B):A=await super().to_json();A['place_holder']=B.place_holder;A['type']=B.type;return A\n+    def __init__(self, type=\"text\", place_holder=None, *args, **kwargs):\n+        super().__init__(tag=\"input\", *args, **kwargs)\n+        self.place_holder = place_holder\n+        self.type = type\n+\n+    async def to_json(self):\n+        data = await super().to_json()\n+        data[\"place_holder\"] = self.place_holder\n+        data[\"type\"] = self.type\n+        return data\n+\n+\n class FormButtonElement(FormElement):\n-\tdef __init__(C,tag='button',*A,**B):super().__init__(*A,tag=tag,**B)\n+    def __init__(self, tag=\"button\", *args, **kwargs):\n+        super().__init__(tag=tag, *args, **kwargs)\n+\n+\n class Form(model.BaseModel):\n-\t@property\n-\tdef html_elements(self):return[A for A in self.fields if isinstance(A,HTMLElement)]\n-\tdef set_user_data(A,data):return super().set_user_data(data.get(_B))\n-\tasync def to_json(D,encode=False):\n-\t\tB='is_valid';E=await super().to_json();C={}\n-\t\tfor A in E.keys():\n-\t\t\tif A==B:continue\n-\t\t\tF=getattr(D,A)\n-\t\t\tif isinstance(F,HTMLElement):\n-\t\t\t\ttry:C[A]=E[A]\n-\t\t\t\texcept KeyError:pass\n-\t\tG=all(A[B]for A in C.values());return{_B:C,B:G,'errors':await D.errors}\n-\t@property\n-\tasync def errors(self):\n-\t\tA=[]\n-\t\tfor B in self.html_elements:A+=await B.errors\n-\t\treturn A\n-\t@property\n-\tasync def is_valid(self):return False\n\\ No newline at end of file\n+    @property\n+    def html_elements(self):\n+        return [element for element in self.fields if isinstance(element, HTMLElement)]\n+\n+    def set_user_data(self, data):\n+        return super().set_user_data(data.get(\"fields\"))\n+\n+    async def to_json(self, encode=False):\n+        elements = await super().to_json()\n+        html_elements = {}\n+        for element in elements.keys():\n+            if element == \"is_valid\":\n+                continue\n+            field = getattr(self, element)\n+            if isinstance(field, HTMLElement):\n+                try:\n+                    html_elements[element] = elements[element]\n+                except KeyError:\n+                    pass\n+\n+        is_valid = all(field[\"is_valid\"] for field in html_elements.values())\n+        return {\n+            \"fields\": html_elements,\n+            \"is_valid\": is_valid,\n+            \"errors\": await self.errors,\n+        }\n+\n+    @property\n+    async def errors(self):\n+        result = []\n+        for field in self.html_elements:\n+            result += await field.errors\n+        return result\n+\n+    @property\n+    async def is_valid(self):\n+        return False\ndiff --git a/src/snek/system/http.py b/src/snek/system/http.py\nindex fa993d5..a1e87a4 100644\n--- a/src/snek/system/http.py\n+++ b/src/snek/system/http.py\n@@ -1,44 +1,110 @@\n-import asyncio,pathlib,uuid,zlib\n+\n+\n+\n+\n+\n+import asyncio\n+import pathlib\n+import uuid\n+import zlib\n from urllib.parse import urljoin\n-import aiohttp,imgkit\n+\n+import aiohttp\n+import imgkit\n from app.cache import time_cache_async\n from bs4 import BeautifulSoup\n+\n+\n async def crc32(data):\n-\tA=data\n-\ttry:A=A.encode()\n-\texcept:pass\n-\treturn'crc32'+str(zlib.crc32(A))\n-async def get_file(name,suffix='.cache'):\n-\tA=name;A=await crc32(A);B=pathlib.Path('.').joinpath('cache')\n-\tif not B.exists():B.mkdir(parents=True,exist_ok=True)\n-\treturn B.joinpath(A+suffix)\n-async def public_touch(name=None):A=pathlib.Path('.').joinpath(str(uuid.uuid4())+name);A.open('wb').close();return A\n+    try:\n+        data = data.encode()\n+    except:\n+        pass\n+    return \"crc32\" + str(zlib.crc32(data))\n+\n+\n+async def get_file(name, suffix=\".cache\"):\n+    name = await crc32(name)\n+    path = pathlib.Path(\".\").joinpath(\"cache\")\n+    if not path.exists():\n+        path.mkdir(parents=True, exist_ok=True)\n+    return path.joinpath(name + suffix)\n+\n+\n+async def public_touch(name=None):\n+    path = pathlib.Path(\".\").joinpath(str(uuid.uuid4()) + name)\n+    path.open(\"wb\").close()\n+    return path\n+\n+\n async def create_site_photo(url):\n-\tA=url;C=asyncio.get_event_loop()\n-\tB=await get_file('site-screenshot-'+A,'.png')\n-\tif B.exists():return B\n-\tB.touch()\n-\tdef D():imgkit.from_url(A,B.absolute());return B\n-\treturn await C.run_in_executor(None,D)\n-async def repair_links(base_url,html_content):\n-\tD='http';E=base_url;B='src';C='href';F=BeautifulSoup(html_content,'html.parser')\n-\tfor A in F.find_all(['a','img','link']):\n-\t\tif A.has_attr(C)and not A[C].startswith(D):A[C]=urljoin(E,A[C])\n-\t\tif A.has_attr(B)and not A[B].startswith(D):A[B]=urljoin(E,A[B])\n-\treturn F.prettify()\n-async def is_html_content(content):\n-\tB=False;A=content\n-\tif not A:return B\n-\ttry:A=A.decode(errors='ignore')\n-\texcept:pass\n-\tC=['<html','<img','<p','<span','<div'];A=A.lower()\n-\tfor D in C:\n-\t\tif D in A:return True\n-\treturn B\n+    loop = asyncio.get_event_loop()\n+    if not url.startswith(\"https\"):\n+    output_path = await get_file(\"site-screenshot-\" + url, \".png\")\n+\n+    if output_path.exists():\n+        return output_path\n+    output_path.touch()\n+\n+    def make_photo():\n+        imgkit.from_url(url, output_path.absolute())\n+        return output_path\n+\n+    return await loop.run_in_executor(None, make_photo)\n+\n+\n+async def repair_links(base_url, html_content):\n+    soup = BeautifulSoup(html_content, \"html.parser\")\n+    for tag in soup.find_all([\"a\", \"img\", \"link\"]):\n+        if tag.has_attr(\"href\") and not tag[\"href\"].startswith(\"http\"):\n+            tag[\"href\"] = urljoin(base_url, tag[\"href\"])\n+        if tag.has_attr(\"src\") and not tag[\"src\"].startswith(\"http\"):\n+            tag[\"src\"] = urljoin(base_url, tag[\"src\"])\n+    return soup.prettify()\n+\n+\n+async def is_html_content(content: bytes):\n+    if not content:\n+        return False\n+    try:\n+        content = content.decode(errors=\"ignore\")\n+    except:\n+        pass\n+    marks = [\"<html\", \"<img\", \"<p\", \"<span\", \"<div\"]\n+    content = content.lower()\n+    for mark in marks:\n+        if mark in content:\n+            return True\n+    return False\n+\n+\n @time_cache_async(120)\n async def get(url):\n-\tasync with aiohttp.ClientSession()as B:\n-\t\tC=await B.get(url);A=await C.text()\n-\t\tif await is_html_content(A):A=(await repair_links(url,A)).encode()\n-\t\treturn A\n\\ No newline at end of file\n+    async with aiohttp.ClientSession() as session:\n+        response = await session.get(url)\n+        content = await response.text()\n+        if await is_html_content(content):\n+            content = (await repair_links(url, content)).encode()\n+        return content\ndiff --git a/src/snek/system/mapper.py b/src/snek/system/mapper.py\nindex beb313e..4a59024 100644\n--- a/src/snek/system/mapper.py\n+++ b/src/snek/system/mapper.py\n@@ -1,37 +1,70 @@\n-_A='uid'\n-DEFAULT_LIMIT=30\n+DEFAULT_LIMIT = 30\n import typing\n+\n from snek.system.model import BaseModel\n+\n+\n class BaseMapper:\n-\tmodel_class:BaseModel=None;default_limit:int=DEFAULT_LIMIT;table_name:str=None\n-\tdef __init__(A,app):A.app=app;A.default_limit=A.__class__.default_limit\n-\t@property\n-\tdef db(self):return self.app.db\n-\tasync def new(A):return A.model_class(mapper=A,app=A.app)\n-\t@property\n-\tdef table(self):return self.db[self.table_name]\n-\tasync def get(B,uid=None,**C):\n-\t\tif uid:C[_A]=uid\n-\t\tA=B.table.find_one(**C)\n-\t\tif not A:return\n-\t\tA=dict(A);D=await B.new()\n-\t\tfor(E,F)in A.items():D[E]=F\n-\t\treturn D;return await B.model_class.from_record(mapper=B,record=A)\n-\tasync def exists(A,**B):return A.table.exists(**B)\n-\tasync def count(A,**B):return A.table.count(**B)\n-\tasync def save(B,model):\n-\t\tA=model\n-\t\tif not A.record.get(_A):raise Exception(f\"Attempt to save without uid: {A.record}.\")\n-\t\tA.updated_at.update();return B.table.upsert(A.record,[_A])\n-\tasync def find(A,**B):\n-\t\tC='_limit'\n-\t\tif not B.get(C):B[C]=A.default_limit\n-\t\tfor E in A.table.find(**B):\n-\t\t\tD=await A.new()\n-\t\t\tfor(F,G)in E.items():D[F]=G\n-\t\t\tyield D\n-\tasync def query(A,sql,*B):\n-\t\tfor C in A.db.query(sql,*B):yield dict(C)\n-\tasync def delete(B,**A):\n-\t\tif not A or not isinstance(A,dict):raise Exception(\"Can't execute delete with no filter.\")\n-\t\treturn B.table.delete(**A)\n\\ No newline at end of file\n+\n+    model_class: BaseModel = None\n+    default_limit: int = DEFAULT_LIMIT\n+    table_name: str = None\n+\n+    def __init__(self, app):\n+        self.app = app\n+\n+        self.default_limit = self.__class__.default_limit\n+\n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    async def new(self):\n+        return self.model_class(mapper=self, app=self.app)\n+\n+    @property\n+    def table(self):\n+        return self.db[self.table_name]\n+\n+    async def get(self, uid: str = None, **kwargs) -> BaseModel:\n+        if uid:\n+            kwargs[\"uid\"] = uid\n+        record = self.table.find_one(**kwargs)\n+        if not record:\n+            return None\n+        record = dict(record)\n+        model = await self.new()\n+        for key, value in record.items():\n+            model[key] = value\n+        return model\n+        return await self.model_class.from_record(mapper=self, record=record)\n+\n+    async def exists(self, **kwargs):\n+        return self.table.exists(**kwargs)\n+\n+    async def count(self, **kwargs) -> int:\n+        return self.table.count(**kwargs)\n+\n+    async def save(self, model: BaseModel) -> bool:\n+        if not model.record.get(\"uid\"):\n+            raise Exception(f\"Attempt to save without uid: {model.record}.\")\n+        model.updated_at.update()\n+        return self.table.upsert(model.record, [\"uid\"])\n+\n+    async def find(self, **kwargs) -> typing.AsyncGenerator:\n+        if not kwargs.get(\"_limit\"):\n+            kwargs[\"_limit\"] = self.default_limit\n+        for record in self.table.find(**kwargs):\n+            model = await self.new()\n+            for key, value in record.items():\n+                model[key] = value\n+            yield model\n+\n+    async def query(self, sql, *args):\n+        for record in self.db.query(sql, *args):\n+            yield dict(record)\n+\n+    async def delete(self, **kwargs) -> int:\n+        if not kwargs or not isinstance(kwargs, dict):\n+            raise Exception(\"Can't execute delete with no filter.\")\n+        return self.table.delete(**kwargs)\ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex b530fb8..82a222e 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -1,35 +1,87 @@\n-_A=True\n+\n from types import SimpleNamespace\n+\n from app.cache import time_cache_async\n-from mistune import HTMLRenderer,Markdown\n+from mistune import HTMLRenderer, Markdown\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n+\n+\n class MarkdownRenderer(HTMLRenderer):\n-\t_allow_harmful_protocols=_A\n-\tdef __init__(A,app,template):A.template=template;A.app=app;A.env=A.app.jinja2_env;B=html.HtmlFormatter();A.env.globals['highlight_styles']=B.get_style_defs()\n-\tdef _escape(A,str):return str\n-\tdef get_lexer(A,lang,default='bash'):\n-\t\ttry:return get_lexer_by_name(lang,stripall=_A)\n-\t\texcept:return get_lexer_by_name(default,stripall=_A)\n-\tdef block_code(B,code,lang=None,info=None):\n-\t\tA=lang\n-\t\tif not A:A=info\n-\t\tif not A:A='bash'\n-\t\tC=B.get_lexer(A);D=html.HtmlFormatter(lineseparator='<br>');E=highlight(code,C,D);return E\n-\tdef render(A):B=A.app.template_path.joinpath(A.template).read_text();C=MarkdownRenderer(A.app,A.template);D=Markdown(renderer=C);return D(B)\n-def render_markdown_sync(app,markdown_string):A=MarkdownRenderer(app,None);B=Markdown(renderer=A);return B(markdown_string)\n+\n+    _allow_harmful_protocols = True\n+\n+    def __init__(self, app, template):\n+        self.template = template\n+\n+        self.app = app\n+        self.env = self.app.jinja2_env\n+        formatter = html.HtmlFormatter()\n+        self.env.globals[\"highlight_styles\"] = formatter.get_style_defs()\n+\n+    def _escape(self, str):\n+\n+    def get_lexer(self, lang, default=\"bash\"):\n+        try:\n+            return get_lexer_by_name(lang, stripall=True)\n+        except:\n+            return get_lexer_by_name(default, stripall=True)\n+\n+    def block_code(self, code, lang=None, info=None):\n+        if not lang:\n+            lang = info\n+        if not lang:\n+            lang = \"bash\"\n+        lexer = self.get_lexer(lang)\n+        formatter = html.HtmlFormatter(lineseparator=\"<br>\")\n+        result = highlight(code, lexer, formatter)\n+        return result\n+\n+    def render(self):\n+        markdown_string = self.app.template_path.joinpath(self.template).read_text()\n+        renderer = MarkdownRenderer(self.app, self.template)\n+        markdown = Markdown(renderer=renderer)\n+        return markdown(markdown_string)\n+\n+\n+def render_markdown_sync(app, markdown_string):\n+    renderer = MarkdownRenderer(app, None)\n+    markdown = Markdown(renderer=renderer)\n+    return markdown(markdown_string)\n+\n+\n @time_cache_async(120)\n-async def render_markdown(app,markdown_string):return render_markdown_sync(app,markdown_string)\n-from jinja2 import TemplateSyntaxError,nodes\n+async def render_markdown(app, markdown_string):\n+    return render_markdown_sync(app, markdown_string)\n+\n+\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n+\n+\n class MarkdownExtension(Extension):\n-\ttags={'markdown'}\n-\tdef __init__(A,environment):B=environment;A.app=SimpleNamespace(jinja2_env=B);super(MarkdownExtension,A).__init__(B)\n-\tdef parse(D,parser):\n-\t\tA=parser;E=next(A.stream).lineno;B=[Const('')];C=''\n-\t\ttry:B=[A.parse_expression()]\n-\t\texcept TemplateSyntaxError:C=A.parse_statements(['name:endmarkdown'],drop_needle=_A)\n-\t\treturn nodes.CallBlock(D.call_method('_to_html',B),[],[],C).set_lineno(E)\n-\tdef _to_html(A,md_file,caller):return render_markdown_sync(A.app,caller())\n\\ No newline at end of file\n+    tags = {\"markdown\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(MarkdownExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endmarkdown\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return render_markdown_sync(self.app, caller())\ndiff --git a/src/snek/system/middleware.py b/src/snek/system/middleware.py\nindex 1437a3f..3a9a055 100644\n--- a/src/snek/system/middleware.py\n+++ b/src/snek/system/middleware.py\n@@ -1,21 +1,53 @@\n-_D='Access-Control-Allow-Credentials'\n-_C='Access-Control-Allow-Headers'\n-_B='Access-Control-Allow-Methods'\n-_A='Access-Control-Allow-Origin'\n+\n+\n+\n+\n from aiohttp import web\n+\n+\n @web.middleware\n-async def no_cors_middleware(request,handler):A=await handler(request);A.headers.pop(_A,None);return A\n+async def no_cors_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers.pop(\"Access-Control-Allow-Origin\", None)\n+    return response\n+\n+\n @web.middleware\n-async def cors_allow_middleware(request,handler):A=await handler(request);A.headers[_A]='*';A.headers[_B]='GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND';A.headers[_C]='*';A.headers[_D]='true';return A\n+async def cors_allow_middleware(request, handler):\n+    response = await handler(request)\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = (\n+        \"GET, POST, OPTIONS, PUT, DELETE, MOVE, COPY, HEAD, LOCK, UNLOCK, PATCH, PROPFIND\"\n+    )\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n+    return response\n+\n+\n @web.middleware\n-async def auth_middleware(request,handler):\n-\tB='uid';C='user';A=request;A[C]=None\n-\tif A.session.get(B)and A.session.get('logged_in'):A[C]=await A.app.services.user.get(uid=A.app.session.get(B))\n-\treturn await handler(A)\n+async def auth_middleware(request, handler):\n+    request[\"user\"] = None\n+    if request.session.get(\"uid\") and request.session.get(\"logged_in\"):\n+        request[\"user\"] = await request.app.services.user.get(\n+            uid=request.app.session.get(\"uid\")\n+        )\n+    return await handler(request)\n+\n+\n @web.middleware\n-async def cors_middleware(request,handler):\n-\tC='Allow';D=handler;B=request\n-\tif B.headers.get(C):return await D(B)\n-\tA=await D(B)\n-\tif B.headers.get(C):return A\n-\tA.headers[_A]='*';A.headers[_B]='GET, POST, PUT, DELETE, OPTIONS';A.headers[_C]='*';A.headers[_D]='true';return A\n\\ No newline at end of file\n+async def cors_middleware(request, handler):\n+    if request.headers.get(\"Allow\"):\n+        return await handler(request)\n+\n+    response = await handler(request)\n+    if request.headers.get(\"Allow\"):\n+        return response\n+    response.headers[\"Access-Control-Allow-Origin\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Methods\"] = \"GET, POST, PUT, DELETE, OPTIONS\"\n+    response.headers[\"Access-Control-Allow-Headers\"] = \"*\"\n+    response.headers[\"Access-Control-Allow-Credentials\"] = \"true\"\n+    return response\ndiff --git a/src/snek/system/model.py b/src/snek/system/model.py\nindex b036329..9e9830d 100644\n--- a/src/snek/system/model.py\n+++ b/src/snek/system/model.py\n@@ -1,139 +1,377 @@\n-_I='deleted_at'\n-_H='updated_at'\n-_G='created_at'\n-_F='is_valid'\n-_E='name'\n-_D=False\n-_C='value'\n-_B=True\n-_A=None\n-import copy,json,re,uuid\n+\n+\n+\n+\n+\n+import copy\n+import json\n+import re\n+import uuid\n from collections import OrderedDict\n-from datetime import datetime,timezone\n-TIMESTAMP_REGEX='^\\\\d{4}-\\\\d{2}-\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{6}\\\\+\\\\d{2}:\\\\d{2}$'\n-def now():return str(datetime.now(timezone.utc))\n-def add_attrs(**A):\n-\tdef B(func):\n-\t\tfor(B,C)in A.items():setattr(func,B,C)\n-\t\treturn func\n-\treturn B\n-def validate_attrs(required=_D,min_length=_A,max_length=_A,regex=_A,**A):\n-\tdef B(func):return add_attrs(required=required,min_length=min_length,max_length=max_length,regex=regex,**A)(func)\n+from datetime import datetime, timezone\n+\n+TIMESTAMP_REGEX = r\"^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{6}\\+\\d{2}:\\d{2}$\"\n+\n+\n+def now():\n+    return str(datetime.now(timezone.utc))\n+\n+\n+def add_attrs(**kwargs):\n+    def decorator(func):\n+        for key, value in kwargs.items():\n+            setattr(func, key, value)\n+        return func\n+\n+    return decorator\n+\n+\n+def validate_attrs(\n+    required=False, min_length=None, max_length=None, regex=None, **kwargs\n+):\n+    def decorator(func):\n+        return add_attrs(\n+            required=required,\n+            min_length=min_length,\n+            max_length=max_length,\n+            regex=regex,\n+            **kwargs,\n+        )(func)\n+\n+\n class Validator:\n-\t_index=0\n-\t@property\n-\tdef value(self):return self._value\n-\t@value.setter\n-\tdef value(self,val):self._value=json.loads(json.dumps(val,default=str))\n-\t@property\n-\tdef initial_value(self):return self.value\n-\tdef custom_validation(A):return _B\n-\tdef __init__(A,required=_D,min_num=_A,max_num=_A,min_length=_A,max_length=_A,regex=_A,value=_A,kind=_A,help_text=_A,app=_A,model=_A,**B):A.index=Validator._index;Validator._index+=1;A.app=app;A.model=model;A.required=required;A.min_num=min_num;A.max_num=max_num;A.min_length=min_length;A.max_length=max_length;A.regex=regex;A._value=_A;A.value=value;A.kind=kind;A.help_text=help_text;A.__dict__.update(B)\n-\t@property\n-\tasync def errors(self):\n-\t\tA=self;B=[]\n-\t\tif A.value is _A and A.required:B.append('Field is required.');return B\n-\t\tif A.value is _A:return B\n-\t\tif A.kind in[int,float]:\n-\t\t\tif A.min_num is not _A and A.value<A.min_num:B.append(f\"Field should be minimal {A.min_num}.\")\n-\t\t\tif A.max_num is not _A and A.value>A.max_num:B.append(f\"Field should be maximal {A.max_num}.\")\n-\t\tif A.min_length is not _A and len(A.value)<A.min_length:B.append(f\"Field should be minimal {A.min_length} characters long.\")\n-\t\tif A.max_length is not _A and len(A.value)>A.max_length:B.append(f\"Field should be maximal {A.max_length} characters long.\")\n-\t\tif A.regex and A.value and not re.match(A.regex,A.value):B.append('Invalid value.')\n-\t\tif A.kind and not isinstance(A.value,A.kind):B.append(f\"Invalid kind. It is supposed to be {A.kind}.\")\n-\t\treturn B\n-\tasync def validate(B):\n-\t\tA=await B.errors\n-\t\tif A:raise ValueError(f\"Errors: {A}.\")\n-\t\treturn _B\n-\tdef __repr__(A):return str(A.to_json())\n-\t@property\n-\tasync def is_valid(self):\n-\t\ttry:await self.validate();return _B\n-\t\texcept ValueError:return _D\n-\tasync def to_json(A):B=await A.errors;C=await A.is_valid;return{'required':A.required,'min_num':A.min_num,'max_num':A.max_num,'min_length':A.min_length,'max_length':A.max_length,'regex':A.regex,_C:A.value,'kind':str(A.kind),'help_text':A.help_text,'errors':B,_F:C,'index':A.index}\n+    _index = 0\n+\n+    @property\n+    def value(self):\n+        return self._value\n+\n+    @value.setter\n+    def value(self, val):\n+        self._value = json.loads(json.dumps(val, default=str))\n+\n+    @property\n+    def initial_value(self):\n+        return self.value\n+\n+    def custom_validation(self):\n+        return True\n+\n+    def __init__(\n+        self,\n+        required=False,\n+        min_num=None,\n+        max_num=None,\n+        min_length=None,\n+        max_length=None,\n+        regex=None,\n+        value=None,\n+        kind=None,\n+        help_text=None,\n+        app=None,\n+        model=None,\n+        **kwargs,\n+    ):\n+        self.index = Validator._index\n+        Validator._index += 1\n+        self.app = app\n+        self.model = model\n+        self.required = required\n+        self.min_num = min_num\n+        self.max_num = max_num\n+        self.min_length = min_length\n+        self.max_length = max_length\n+        self.regex = regex\n+        self._value = None\n+        self.value = value\n+        self.kind = kind\n+        self.help_text = help_text\n+        self.__dict__.update(kwargs)\n+\n+    @property\n+    async def errors(self):\n+        error_list = []\n+        if self.value is None and self.required:\n+            error_list.append(\"Field is required.\")\n+            return error_list\n+\n+        if self.value is None:\n+            return error_list\n+\n+        if self.kind in [int, float]:\n+            if self.min_num is not None and self.value < self.min_num:\n+                error_list.append(f\"Field should be minimal {self.min_num}.\")\n+            if self.max_num is not None and self.value > self.max_num:\n+                error_list.append(f\"Field should be maximal {self.max_num}.\")\n+        if self.min_length is not None and len(self.value) < self.min_length:\n+            error_list.append(\n+                f\"Field should be minimal {self.min_length} characters long.\"\n+            )\n+        if self.max_length is not None and len(self.value) > self.max_length:\n+            error_list.append(\n+                f\"Field should be maximal {self.max_length} characters long.\"\n+            )\n+        if self.regex and self.value and not re.match(self.regex, self.value):\n+            error_list.append(\"Invalid value.\")\n+        if self.kind and not isinstance(self.value, self.kind):\n+            error_list.append(f\"Invalid kind. It is supposed to be {self.kind}.\")\n+        return error_list\n+\n+    async def validate(self):\n+        errors = await self.errors\n+        if errors:\n+            raise ValueError(f\"Errors: {errors}.\")\n+        return True\n+\n+    def __repr__(self):\n+        return str(self.to_json())\n+\n+    @property\n+    async def is_valid(self):\n+        try:\n+            await self.validate()\n+            return True\n+        except ValueError:\n+            return False\n+\n+    async def to_json(self):\n+        errors = await self.errors\n+        is_valid = await self.is_valid\n+        return {\n+            \"required\": self.required,\n+            \"min_num\": self.min_num,\n+            \"max_num\": self.max_num,\n+            \"min_length\": self.min_length,\n+            \"max_length\": self.max_length,\n+            \"regex\": self.regex,\n+            \"value\": self.value,\n+            \"kind\": str(self.kind),\n+            \"help_text\": self.help_text,\n+            \"errors\": errors,\n+            \"is_valid\": is_valid,\n+            \"index\": self.index,\n+        }\n+\n+\n class ModelField(Validator):\n-\tindex=1\n-\tdef __init__(A,name=_A,save=_B,*B,**C):A.name=name;A.save=save;super().__init__(*B,**C)\n-\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;return A\n+\n+    index = 1\n+\n+    def __init__(self, name=None, save=True, *args, **kwargs):\n+        self.name = name\n+        self.save = save\n+        super().__init__(*args, **kwargs)\n+\n+    async def to_json(self):\n+        result = await super().to_json()\n+        result[\"name\"] = self.name\n+        return result\n+\n+\n class CreatedField(ModelField):\n-\t@property\n-\tdef initial_value(self):return now()\n-\tdef update(A):\n-\t\tif not A.value:A.value=now()\n+\n+    @property\n+    def initial_value(self):\n+        return now()\n+\n+    def update(self):\n+        if not self.value:\n+            self.value = now()\n+\n+\n class UpdatedField(ModelField):\n-\tdef update(A):A.value=now()\n+\n+    def update(self):\n+        self.value = now()\n+\n+\n class DeletedField(ModelField):\n-\tdef update(A):A.value=now()\n+\n+    def update(self):\n+        self.value = now()\n+\n+\n class UUIDField(ModelField):\n-\t@property\n-\tdef value(self):return str(self._value)\n-\t@value.setter\n-\tdef value(self,val):self._value=str(val)\n-\t@property\n-\tdef initial_value(self):return str(uuid.uuid4())\n+\n+    @property\n+    def value(self):\n+        return str(self._value)\n+\n+    @value.setter\n+    def value(self, val):\n+        self._value = str(val)\n+\n+    @property\n+    def initial_value(self):\n+        return str(uuid.uuid4())\n+\n+\n class BaseModel:\n-\tuid=UUIDField(name='uid',required=_B);created_at=CreatedField(name=_G,required=_B,regex=TIMESTAMP_REGEX,place_holder='Created at');updated_at=UpdatedField(name=_H,regex=TIMESTAMP_REGEX,place_holder='Updated at');deleted_at=DeletedField(name=_I,regex=TIMESTAMP_REGEX,place_holder='Deleted at')\n-\t@classmethod\n-\tasync def from_record(B,record,mapper):A=B();A.mapper=mapper;A.record=record;return A\n-\t@property\n-\tdef mapper(self):return self._mapper\n-\t@mapper.setter\n-\tdef mapper(self,value):self._mapper=value\n-\t@property\n-\tdef record(self):return{A:B.value for(A,B)in self.fields.items()}\n-\t@record.setter\n-\tdef record(self,val):\n-\t\tA=self\n-\t\tfor(B,C)in val.items():\n-\t\t\tD=A.fields.get(B)\n-\t\t\tif not D:continue\n-\t\t\tA[B]=C\n-\t\treturn A\n-\tdef __init__(A,*F,**C):\n-\t\tD='app';A._mapper=C.get('mapper');A.app=C.get(D);A.fields={}\n-\t\tfor B in dir(A.__class__):\n-\t\t\tE=getattr(A.__class__,B)\n-\t\t\tif isinstance(E,Validator):A.__dict__[B]=copy.deepcopy(E);A.__dict__[B].value=C.pop(B,A.__dict__[B].initial_value);A.fields[B]=A.__dict__[B];A.fields[B].model=A;A.fields[B].app=C.get(D)\n-\tdef __setitem__(B,key,value):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):A.value=value\n-\tdef __getattr__(B,key):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):return A.value\n-\t\treturn A\n-\tdef set_user_data(C,data):\n-\t\tfor(D,A)in data.items():\n-\t\t\tB=C.fields.get(D)\n-\t\t\tif not B:continue\n-\t\t\tif A.get(_E):A=A.get(_C)\n-\t\t\tB.value=A\n-\t@property\n-\tasync def is_valid(self):return all([await A.is_valid for A in self.fields.values()])\n-\tdef __getitem__(B,key):\n-\t\tA=B.__dict__.get(key)\n-\t\tif isinstance(A,Validator):return A.value\n-\tdef __setattr__(A,key,value):\n-\t\tB=value;C=getattr(A,key)\n-\t\tif isinstance(C,Validator):C.value=B\n-\t\telse:A.__dict__[key]=B\n-\t@property\n-\tasync def recordz(self):\n-\t\tD=await self.to_json();B={}\n-\t\tfor(C,A)in D.items():\n-\t\t\tif not isinstance(A,dict)or _C not in A:continue\n-\t\t\tif getattr(self,C).save:B[C]=A.get(_C)\n-\t\treturn B\n-\tasync def to_json(A,encode=_D):\n-\t\tB=OrderedDict({'uid':A.uid.value,_G:A.created_at.value,_H:A.updated_at.value,_I:A.deleted_at.value,_F:await A.is_valid})\n-\t\tfor(C,D)in A.fields.items():\n-\t\t\tif C=='record':continue\n-\t\t\tD=A.__dict__[C]\n-\t\t\tif hasattr(D,_C):B[C]=await D.to_json()\n-\t\tif encode:return json.dumps(B,indent=2)\n-\t\treturn B\n+\n+    uid = UUIDField(name=\"uid\", required=True)\n+    created_at = CreatedField(\n+        name=\"created_at\",\n+        required=True,\n+        regex=TIMESTAMP_REGEX,\n+        place_holder=\"Created at\",\n+    )\n+    updated_at = UpdatedField(\n+        name=\"updated_at\", regex=TIMESTAMP_REGEX, place_holder=\"Updated at\"\n+    )\n+    deleted_at = DeletedField(\n+        name=\"deleted_at\", regex=TIMESTAMP_REGEX, place_holder=\"Deleted at\"\n+    )\n+\n+    @classmethod\n+    async def from_record(cls, record, mapper):\n+        model = cls()\n+        model.mapper = mapper\n+        model.record = record\n+        return model\n+\n+    @property\n+    def mapper(self):\n+        return self._mapper\n+\n+    @mapper.setter\n+    def mapper(self, value):\n+        self._mapper = value\n+\n+    @property\n+    def record(self):\n+        return {key: field.value for key, field in self.fields.items()}\n+\n+    @record.setter\n+    def record(self, val):\n+        for key, value in val.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            self[key] = value\n+        return self\n+\n+    def __init__(self, *args, **kwargs):\n+        self._mapper = kwargs.get(\"mapper\")\n+        self.app = kwargs.get(\"app\")\n+        self.fields = {}\n+        for key in dir(self.__class__):\n+            obj = getattr(self.__class__, key)\n+\n+            if isinstance(obj, Validator):\n+                self.__dict__[key] = copy.deepcopy(obj)\n+                self.__dict__[key].value = kwargs.pop(\n+                    key, self.__dict__[key].initial_value\n+                )\n+                self.fields[key] = self.__dict__[key]\n+                self.fields[key].model = self\n+                self.fields[key].app = kwargs.get(\"app\")\n+\n+    def __setitem__(self, key, value):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            obj.value = value\n+\n+    def __getattr__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            return obj.value\n+        return obj\n+\n+    def set_user_data(self, data):\n+        for key, value in data.items():\n+            field = self.fields.get(key)\n+            if not field:\n+                continue\n+            if value.get(\"name\"):\n+                value = value.get(\"value\")\n+            field.value = value\n+\n+    @property\n+    async def is_valid(self):\n+        return all([await field.is_valid for field in self.fields.values()])\n+\n+    def __getitem__(self, key):\n+        obj = self.__dict__.get(key)\n+        if isinstance(obj, Validator):\n+            return obj.value\n+\n+    def __setattr__(self, key, value):\n+        obj = getattr(self, key)\n+        if isinstance(obj, Validator):\n+            obj.value = value\n+        else:\n+            self.__dict__[key] = value\n+\n+    @property\n+    async def recordz(self):\n+        obj = await self.to_json()\n+        record = {}\n+        for key, value in obj.items():\n+            if not isinstance(value, dict) or \"value\" not in value:\n+                continue\n+            if getattr(self, key).save:\n+                record[key] = value.get(\"value\")\n+        return record\n+\n+    async def to_json(self, encode=False):\n+        model_data = OrderedDict(\n+            {\n+                \"uid\": self.uid.value,\n+                \"created_at\": self.created_at.value,\n+                \"updated_at\": self.updated_at.value,\n+                \"deleted_at\": self.deleted_at.value,\n+                \"is_valid\": await self.is_valid,\n+            }\n+        )\n+\n+        for key, value in self.fields.items():\n+            if key == \"record\":\n+                continue\n+            value = self.__dict__[key]\n+            if hasattr(value, \"value\"):\n+                model_data[key] = await value.to_json()\n+        if encode:\n+            return json.dumps(model_data, indent=2)\n+        return model_data\n+\n+\n class FormElement(ModelField):\n-\tdef __init__(A,place_holder=_A,*B,**C):super().__init__(*B,**C);A.place_holder=place_holder\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        super().__init__(*args, **kwargs)\n+        self.place_holder = place_holder\n+\n+\n class FormElement(ModelField):\n-\tdef __init__(A,place_holder=_A,*B,**C):A.place_holder=place_holder;super().__init__(*B,**C)\n-\tasync def to_json(B):A=await super().to_json();A[_E]=B.name;A['place_holder']=B.place_holder;return A\n\\ No newline at end of file\n+\n+    def __init__(self, place_holder=None, *args, **kwargs):\n+        self.place_holder = place_holder\n+        super().__init__(*args, **kwargs)\n+\n+    async def to_json(self):\n+        data = await super().to_json()\n+        data[\"name\"] = self.name\n+        data[\"place_holder\"] = self.place_holder\n+        return data\ndiff --git a/src/snek/system/object.py b/src/snek/system/object.py\nindex a36bb76..f91ec42 100644\n--- a/src/snek/system/object.py\n+++ b/src/snek/system/object.py\n@@ -1,7 +1,13 @@\n class Object:\n-\tdef __init__(A,*C,**D):\n-\t\tfor B in C:\n-\t\t\tif isinstance(B,dict):A.__dict__.update(B)\n-\t\tA.__dict__.update(D)\n-\tdef __getitem__(A,key):return A.__dict__[key]\n-\tdef __setitem__(A,key,value):A.__dict__[key]=value\n\\ No newline at end of file\n+\n+    def __init__(self, *args, **kwargs):\n+        for arg in args:\n+            if isinstance(arg, dict):\n+                self.__dict__.update(arg)\n+        self.__dict__.update(kwargs)\n+\n+    def __getitem__(self, key):\n+        return self.__dict__[key]\n+\n+    def __setitem__(self, key, value):\n+        self.__dict__[key] = value\ndiff --git a/src/snek/system/profiler.py b/src/snek/system/profiler.py\nindex d196b30..e0e5542 100644\n--- a/src/snek/system/profiler.py\n+++ b/src/snek/system/profiler.py\n@@ -1,17 +1,46 @@\n-import cProfile,pstats,sys\n+import cProfile\n+import pstats\n+import sys\n+\n from aiohttp import web\n-profiler=None\n+\n+profiler = None\n import io\n+\n+\n @web.middleware\n-async def profile_middleware(request,handler):\n-\tglobal profiler\n-\tif not profiler:profiler=cProfile.Profile()\n-\tprofiler.enable();B=await handler(request);profiler.disable();A=pstats.Stats(profiler,stream=sys.stdout);A.sort_stats('cumulative');A.print_stats();return B\n-async def profiler_handler(request):A=io.StringIO();B=pstats.Stats(profiler,stream=A);C=request.query.get('sort','tot. percall');B.sort_stats(C);B.print_stats();return web.Response(text=A.getvalue())\n+async def profile_middleware(request, handler):\n+    global profiler\n+    if not profiler:\n+        profiler = cProfile.Profile()\n+    profiler.enable()\n+    response = await handler(request)\n+    profiler.disable()\n+    stats = pstats.Stats(profiler, stream=sys.stdout)\n+    stats.sort_stats(\"cumulative\")\n+    stats.print_stats()\n+    return response\n+\n+\n+async def profiler_handler(request):\n+    output = io.StringIO()\n+    stats = pstats.Stats(profiler, stream=output)\n+    sort_by = request.query.get(\"sort\", \"tot. percall\")\n+    stats.sort_stats(sort_by)\n+    stats.print_stats()\n+    return web.Response(text=output.getvalue())\n+\n+\n class Profiler:\n-\tdef __init__(A):\n-\t\tglobal profiler\n-\t\tif profiler is None:profiler=cProfile.Profile()\n-\t\tA.profiler=profiler\n-\tasync def __aenter__(A):A.profiler.enable()\n-\tasync def __aexit__(A,*B,**C):A.profiler.disable()\n\\ No newline at end of file\n+\n+    def __init__(self):\n+        global profiler\n+        if profiler is None:\n+            profiler = cProfile.Profile()\n+        self.profiler = profiler\n+\n+    async def __aenter__(self):\n+        self.profiler.enable()\n+\n+    async def __aexit__(self, *args, **kwargs):\n+        self.profiler.disable()\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 8d5ced9..43b61fe 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -1,24 +1,77 @@\n-_A='snekker-de-snek-'\n-import hashlib,uuid\n-DEFAULT_SALT=_A\n-DEFAULT_NS=_A\n+import hashlib\n+import uuid\n+\n+DEFAULT_SALT = \"snekker-de-snek-\"\n+DEFAULT_NS = \"snekker-de-snek-\"\n+\n+\n class UIDNS:\n-\tdef __init__(A,name):'Initialize UIDNS with a name.';A.name=name\n-\t@property\n-\tdef bytes(self):'Return the bytes representation of the name.';return self.name.encode()\n-def uid(value=None,ns=DEFAULT_NS):\n-\t'Generate a UUID based on the provided value and namespace.\\n\\n    Args:\\n        value (str): The value to generate the UUID from. If None, a new UUID is created.\\n        ns (str): The namespace to use for UUID generation.\\n\\n    Returns:\\n        str: The generated UUID as a string.\\n    ';A=value\n-\ttry:ns=ns.decode()\n-\texcept AttributeError:pass\n-\tif not A:A=str(uuid.uuid4())\n-\ttry:A=A.decode()\n-\texcept AttributeError:pass\n-\treturn str(uuid.uuid5(UIDNS(ns),A))\n-async def hash(data,salt=DEFAULT_SALT):\n-\t'Hash the given data with the specified salt using SHA-256.\\n\\n    Args:\\n        data (str): The data to hash.\\n        salt (str): The salt to use for hashing.\\n\\n    Returns:\\n        str: The hexadecimal representation of the hashed data.\\n    ';C='ignore';A=salt;B=data\n-\ttry:B=B.encode(errors=C)\n-\texcept AttributeError:pass\n-\ttry:A=A.encode(errors=C)\n-\texcept AttributeError:pass\n-\tD=A+B;E=hashlib.sha256(D);return E.hexdigest()\n-async def verify(string,hashed):'Verify if the given string matches the hashed value.\\n\\n    Args:\\n        string (str): The string to verify.\\n        hashed (str): The hashed value to compare against.\\n\\n    Returns:\\n        bool: True if the string matches the hashed value, False otherwise.\\n    ';return await hash(string)==hashed\n\\ No newline at end of file\n+    def __init__(self, name: str) -> None:\n+        \"\"\"Initialize UIDNS with a name.\"\"\"\n+        self.name = name\n+\n+    @property\n+    def bytes(self) -> bytes:\n+        \"\"\"Return the bytes representation of the name.\"\"\"\n+        return self.name.encode()\n+\n+\n+def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n+    \"\"\"Generate a UUID based on the provided value and namespace.\n+\n+    Args:\n+        value (str): The value to generate the UUID from. If None, a new UUID is created.\n+        ns (str): The namespace to use for UUID generation.\n+\n+    Returns:\n+        str: The generated UUID as a string.\n+    \"\"\"\n+    try:\n+        ns = ns.decode()\n+    except AttributeError:\n+        pass\n+    if not value:\n+        value = str(uuid.uuid4())\n+    try:\n+        value = value.decode()\n+    except AttributeError:\n+        pass\n+\n+    return str(uuid.uuid5(UIDNS(ns), value))\n+\n+\n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    \"\"\"Hash the given data with the specified salt using SHA-256.\n+\n+    Args:\n+        data (str): The data to hash.\n+        salt (str): The salt to use for hashing.\n+\n+    Returns:\n+        str: The hexadecimal representation of the hashed data.\n+    \"\"\"\n+    try:\n+        data = data.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    try:\n+        salt = salt.encode(errors=\"ignore\")\n+    except AttributeError:\n+        pass\n+    salted = salt + data\n+\n+    obj = hashlib.sha256(salted)\n+    return obj.hexdigest()\n+\n+\n+async def verify(string: str, hashed: str) -> bool:\n+    \"\"\"Verify if the given string matches the hashed value.\n+\n+    Args:\n+        string (str): The string to verify.\n+        hashed (str): The hashed value to compare against.\n+\n+    Returns:\n+        bool: True if the string matches the hashed value, False otherwise.\n+    \"\"\"\n+    return await hash(string) == hashed\ndiff --git a/src/snek/system/service.py b/src/snek/system/service.py\nindex eb735b1..c6d2afc 100644\n--- a/src/snek/system/service.py\n+++ b/src/snek/system/service.py\n@@ -1,42 +1,67 @@\n-_B='uid'\n-_A=None\n from snek.mapper import get_mapper\n from snek.model.user import UserModel\n from snek.system.mapper import BaseMapper\n+\n+\n class BaseService:\n-\tmapper_name:BaseMapper=_A\n-\t@property\n-\tdef services(self):return self.app.services\n-\tdef __init__(A,app):\n-\t\tA.app=app;A.cache=app.cache\n-\t\tif A.mapper_name:A.mapper=get_mapper(A.mapper_name,app=A.app)\n-\t\telse:A.mapper=_A\n-\tasync def exists(C,uid=_A,**A):\n-\t\tB=uid\n-\t\tif B:\n-\t\t\tif not A and await C.cache.get(B):return True\n-\t\t\tA[_B]=B\n-\t\treturn await C.count(**A)>0\n-\tasync def count(A,**B):return await A.mapper.count(**B)\n-\tasync def new(A,**B):return await A.mapper.new()\n-\tasync def query(A,sql,*B):\n-\t\tfor C in A.app.db.query(sql,*B):yield C\n-\tasync def get(B,uid=_A,**C):\n-\t\tD=uid\n-\t\tif D:\n-\t\t\tif not C:\n-\t\t\t\tA=await B.cache.get(D)\n-\t\t\t\tif False and A and A.__class__==B.mapper.model_class:return A\n-\t\t\tC[_B]=D\n-\t\tA=await B.mapper.get(**C)\n-\t\tif A:await B.cache.set(A[_B],A)\n-\t\treturn A\n-\tasync def save(B,model):\n-\t\tA=model\n-\t\tif await B.mapper.save(A):await B.cache.set(A[_B],A);return True\n-\t\tC=await A.errors;raise Exception(f\"Couldn't save model. Errors: f{C}\")\n-\tasync def find(C,**A):\n-\t\tB='_limit'\n-\t\tif B not in A or int(A.get(B))>30:A[B]=60\n-\t\tasync for D in C.mapper.find(**A):yield D\n-\tasync def delete(A,**B):return await A.mapper.delete(**B)\n\\ No newline at end of file\n+\n+    mapper_name: BaseMapper = None\n+\n+    @property\n+    def services(self):\n+        return self.app.services\n+\n+    def __init__(self, app):\n+        self.app = app\n+        self.cache = app.cache\n+        if self.mapper_name:\n+            self.mapper = get_mapper(self.mapper_name, app=self.app)\n+        else:\n+            self.mapper = None\n+\n+    async def exists(self, uid=None, **kwargs):\n+        if uid:\n+            if not kwargs and await self.cache.get(uid):\n+                return True\n+            kwargs[\"uid\"] = uid\n+        return await self.count(**kwargs) > 0\n+\n+    async def count(self, **kwargs):\n+        return await self.mapper.count(**kwargs)\n+\n+    async def new(self, **kwargs):\n+        return await self.mapper.new()\n+\n+    async def query(self, sql, *args):\n+        for record in self.app.db.query(sql, *args):\n+            yield record\n+\n+    async def get(self, uid=None, **kwargs):\n+        if uid:\n+            if not kwargs:\n+                result = await self.cache.get(uid)\n+                if False and result and result.__class__ == self.mapper.model_class:\n+                    return result\n+            kwargs[\"uid\"] = uid\n+\n+        result = await self.mapper.get(**kwargs)\n+        if result:\n+            await self.cache.set(result[\"uid\"], result)\n+        return result\n+\n+    async def save(self, model: UserModel):\n+        if await self.mapper.save(model):\n+            await self.cache.set(model[\"uid\"], model)\n+            return True\n+        errors = await model.errors\n+        raise Exception(f\"Couldn't save model. Errors: f{errors}\")\n+\n+    async def find(self, **kwargs):\n+        if \"_limit\" not in kwargs or int(kwargs.get(\"_limit\")) > 30:\n+            kwargs[\"_limit\"] = 60\n+        async for model in self.mapper.find(**kwargs):\n+            yield model\n+\n+    async def delete(self, **kwargs):\n+        return await self.mapper.delete(**kwargs)\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 1630219..d4b6819 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,82 +1,249 @@\n-_G=':snek1:'\n-_F='status'\n-_E='_to_html'\n-_D='alias'\n-_C=True\n-_B='html.parser'\n-_A='href'\n import re\n from types import SimpleNamespace\n+\n import emoji\n from bs4 import BeautifulSoup\n-from jinja2 import TemplateSyntaxError,nodes\n+from jinja2 import TemplateSyntaxError, nodes\n from jinja2.ext import Extension\n from jinja2.nodes import Const\n-emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />']={'en':_G,_F:2,'E':.6,_D:[_G]}\n-emoji.EMOJI_DATA['\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\\n']={'en':':a1:',_F:2,'E':.6,_D:[':a1:']}\n+\n+emoji.EMOJI_DATA['<img src=\"/emoji/snek1.gif\" />'] = {\n+    \"en\": \":snek1:\",\n+    \"status\": 2,\n+    \"E\": 0.6,\n+    \"alias\": [\":snek1:\"],\n+}\n+\n+emoji.EMOJI_DATA[\n+    \"\"\"\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28c0\u28e0\u28e4\u2874\u2836\u28b6\u28de\u28db\u28db\u2873\u28f3\u2836\u28f6\u2876\u28b6\u28b6\u28e6\u28e4\u28c4\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28e4\u2836\u281a\u28eb\u28f4\u28ec\u2810\u28f6\u28ff\u28ff\u28cf\u28fd\u28ff\u28ff\u28c7\u28bf\u28ef\u28ff\u28ff\u28fb\u28ff\u28ff\u28fe\u28ee\u28f9\u28ff\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28f4\u28be\u28fb\u28fd\u28fe\u2847\u28a1\u28ff\u28ff\u28c7\u285f\u28ff\u28ff\u28ff\u28fc\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28df\u28ff\u28f7\u28bb\u28ff\u28ff\u28ff\u28f7\u287d\u28e7\u28f9\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28f4\u281f\u28eb\u28f6\u28ff\u28df\u28fe\u287f\u2800\u28fe\u28ff\u28ff\u28b9\u28bf\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28ff\u284f\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28de\u28ff\u28ff\u28ef\u287b\u28e6\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u2874\u28db\u28f5\u28fb\u28df\u28ff\u28af\u28ff\u289f\u2846\u28c0\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28ff\u28ff\u28ff\u287e\u28ff\u28fb\u28ff\u28ee\u283b\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28ff\u2840\u2800\u2800\u2800\u2880\u28e0\u287e\u28fb\u28fe\u287f\u28f3\u285f\u28fe\u28bf\u28ff\u28af\u28ff\u2847\u28b8\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28cf\u28ff\u28ff\u2875\u28dd\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28bb\u28df\u28a7\u28e4\u28f6\u28be\u28fb\u28ff\u28fe\u28ff\u28ff\u28b3\u28ff\u28bb\u28cf\u28ff\u28bf\u28ff\u287f\u2800\u28fe\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28d7\u28ff\u28ff\u28ff\u28ff\u28ff\u288f\u28ff\u28ff\u280b\u28ff\u28ff\u285f\u2848\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28f9\u28ff\u28b8\u28ff\u28e7\u28bb\u28ee\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u283f\u283f\u283f\u28bf\u285f\u28ff\u28ff\u28ff\u28a7\u28ff\u28df\u285f\u28fe\u28ef\u28ff\u287f\u28fd\u2847\u28ff\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u2839\u28ff\u287f\u28bb\u287f\u28a3\u28ff\u287f\u28f3\u28d6\u28ff\u28ff\u28b1\u28ff\u28b9\u28ff\u28ff\u2800\u28ff\u28ff\u2818\u28ff\u285e\u28ff\u28ff\u2878\u28ff\u28ef\u28bf\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28fc\u28ff\u28ff\u287f\u28fc\u28ff\u28ff\u28a7\u28ff\u28fc\u28ff\u28fb\u28ff\u2843\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c0\u28ff\u28a7\u2818\u28e3\u28b8\u287f\u28fb\u28ff\u28ff\u2858\u281f\u28ff\u28ff\u2858\u283f\u281f\u28df\u28fb\u287f\u28c0\u28bf\u2847\u28bb\u28ff\u2847\u28ff\u28ff\u28b8\u28ff\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28cf\u28ff\u28ff\u28ff\u28b3\u28ff\u28ff\u28ff\u28f8\u28b7\u28ff\u28b7\u28ff\u289f\u28a8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u28ff\u28e6\u2816\u2818\u285b\u28e1\u282c\u282d\u282d\u281b\u2802\u2830\u28ff\u28ff\u2847\u2800\u28f0\u284f\u289b\u28f4\u28ff\u284f\u28b8\u28fc\u287f\u2807\u28ff\u28ff\u28e7\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u2847\u28ff\u28fe\u28ef\u28ff\u28ef\u287f\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28bf\u2843\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e4\u28d7\u280a\u28fb\u28ff\u28f6\u28ff\u28f6\u28ff\u28ff\u28ff\u2847\u28fe\u280f\u2841\u2800\u28ff\u28ff\u28e7\u28f9\u28ff\u28ff\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28e7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u28ff\u2847\u28ff\u287f\u28fe\u2833\u280b\u28c4\u28fc\u28ff\u28ff\u28ff\u28fb\u28fe\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28eb\u28e4\u28c4\u28c0\u28ff\u2807\u28ff\u28ff\u28ff\u28fb\u284c\u28ff\u28ff\u2846\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u28cf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c7\u2880\u28fe\u284b\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28f1\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ec\u28fb\u28ff\u28fd\u2847\u2838\u28ff\u28f7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28c7\u28ff\u28ff\u28ff\u28f9\u28ff\u28ff\u288f\u28c5\u2880\u2832\u28e4\u28d9\u283c\u28bf\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u283b\u281f\u283b\u283f\u283f\u28f7\u28fd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28e7\u28ff\u28be\u2847\u2800\u28bf\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u287f\u28fe\u28ff\u28cf\u28fe\u2807\u2808\u2800\u2808\u28bb\u28ff\u28f7\u2841\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u28e4\u28f4\u28f6\u2800\u2800\u2800\u2800\u28ca\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28fb\u28ff\u2847\u28ff\u28ff\u28ff\u28fb\u2847\u2800\u28b8\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u284f\u28ff\u28ff\u28ff\u2857\u28ff\u28ff\u28b8\u285f\u28fc\u28c0\u280f\u2801\u2840\u28bf\u28ff\u2847\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28bf\u28ef\u2800\u2844\u2800\u2801\u2809\u2819\u28ff\u28ee\u285d\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u2801\u2800\u28c0\u28c0\u2840\u2801\u28bf\u28fb\u28ff\u28ff\u2847\u2800\u2808\u28ff\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a3\u28ff\u28ff\u28ff\u28b8\u28ff\u28cf\u28be\u2867\u287f\u280b\u2809\u2881\u28e1\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c3\u28f7\u28dd\u28c0\u28fd\u28e4\u28e7\u28e4\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u28c0\u2800\u2800\u2809\u28a9\u28ff\u2847\u28f6\u28f3\u28ff\u28fd\u2847\u2800\u28a0\u287f\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f8\u28ff\u28ff\u287f\u28fc\u28ff\u28f7\u2838\u2803\u28bb\u285c\u28bf\u28ff\u289f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28ff\u28f7\u28e5\u28e4\u28e5\u28e4\u28ff\u28ff\u2845\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u28fc\u28ff\u284f\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28b7\u28c4\u28c9\u282e\u2809\u2819\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u2847\u28bc\u28ff\u285f\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ee\u28bf\u28ff\u28ff\u28f7\u28e6\u2848\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2887\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f5\u285d\u28bf\u28ff\u28ff\u28ff\u28f6\u28cd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2889\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ff\u28ff\u28bf\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2830\u285f\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u28ff\u28bb\u28b3\u28b8\u28a9\u28df\u28db\u2813\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u28ff\u28ff\u28fc\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u2807\u28ff\u28ff\u28ff\u2807\u28ff\u28b8\u28b8\u28f8\u2847\u28b8\u28ff\u2807\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b9\u28ff\u28ef\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u283f\u28bf\u28bf\u281b\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28b8\u28ff\u28ff\u28ff\u28be\u2843\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b8\u28f7\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28ff\u28fb\u28ff\u28b9\u28ff\u28f8\u2818\u287f\u2847\u28b8\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u289b\u28f5\u28f6\u28f6\u28ec\u2800\u28a0\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2884\u28f8\u28ff\u28ff\u28ff\u28ff\u2841\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u285f\u28b8\u287f\u28ff\u28ff\u28b8\u2847\u2856\u2844\u2807\u28c7\u28b8\u28ff\u2853\u28bb\u28ff\u28ff\u28ff\u28ff\u28ff\u28ba\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28a7\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280b\u28a8\u2800\u28ff\u28ff\u28ff\u28cf\u28ff\u2804\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u284f\u28fe\u28ff\u28ff\u28ff\u28ff\u2803\u28fc\u28f7\u28ff\u284f\u28ff\u28ff\u2807\u28b9\u28b8\u28ff\u28ba\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c4\u28d9\u285b\u28db\u28a1\u28fe\u28ff\u283b\u28bf\u28ff\u28ff\u28df\u28f1\u2846\u2828\u2800\u28ff\u28ff\u28bf\u2857\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28f9\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28fc\u28f9\u28ff\u28a3\u28ff\u28ff\u28a0\u28f8\u28fc\u28ff\u285e\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u28e6\u28df\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28f7\u28fe\u28bf\u28ff\u28ff\u2808\u28b7\u285d\u28bb\u284a\u2801\u28e7\u2800\u2803\u28ff\u28ff\u2846\u288b\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28ff\u28ff\u28ff\u28f8\u28ff\u28ff\u28f8\u28bb\u28ff\u28ff\u2857\u28ff\u28ff\u284f\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ff\u28ec\u28d9\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u2846\u28a8\u28c1\u28d8\u28f7\u28c4\u28ff\u2800\u28a0\u28ff\u28ff\u28e3\u28c8\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2880\u282f\u28ff\u284f\u28ff\u28ff\u2847\u28bb\u28fd\u28ff\u28ff\u28df\u28fb\u28ff\u2847\u28ff\u28ff\u284f\u28ff\u28ff\u28f0\u287c\u28ba\u287e\u28cf\u28ff\u28e8\u285f\u283f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u289f\u2849\u2846\u284e\u283f\u28ff\u28ff\u28ee\u287b\u2847\u28b8\u28ff\u28cf\u28bf\u28ff\u28de\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fe\u28ff\u28ff\u2887\u28fe\u28ff\u28ff\u288b\u28e0\u28b0\u28ff\u28bf\u28ff\u28ff\u28b0\u28b8\u28b9\u28ff\u28ff\u28ff\u28f8\u28ff\u28e7\u28ff\u28ff\u2847\u28ff\u28ff\u28bc\u28ff\u28e6\u2879\u28b9\u285c\u28c7\u28bf\u28f9\u28f6\u28cd\u285b\u283b\u283f\u28eb\u2856\u285f\u28e0\u28f1\u28e7\u28ff\u28de\u28fb\u28ff\u28ff\u28e6\u2878\u28ff\u28ff\u285c\u28ff\u28ff\u28ef\u28bf\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fe\u28ff\u28ff\u288f\u28fe\u28ff\u28ff\u284f\u28fe\u284f\u28b8\u28ff\u28fe\u28ff\u2847\u2806\u28ff\u28b8\u28ff\u28ff\u28ff\u284f\u28ff\u28f7\u28b9\u28ff\u28f7\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28e6\u287b\u2878\u285e\u28e7\u28bf\u2827\u28c7\u2819\u283f\u28ff\u28fc\u28a3\u2803\u288b\u2803\u28ff\u2808\u2833\u28dd\u28bf\u28ff\u28ff\u28ee\u28dd\u28bf\u287c\u28ff\u28ff\u28f7\u2869\u28fd\u28fb\u28b6\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28fe\u28af\u28ff\u28ff\u287f\u28f3\u28ff\u28ff\u28ff\u287f\u28b8\u28ff\u2807\u28fa\u28ff\u28af\u28ff\u285d\u28f8\u287f\u2858\u28fd\u28ff\u28ff\u28d7\u28bf\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u2826\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ed\u28d8\u280e\u2818\u28a3\u28f7\u2846\u2802\u2825\u2801\u28c2\u285f\u281f\u28bf\u28c0\u2800\u2809\u2833\u28dd\u28bf\u28ff\u28ff\u28ff\u28e6\u28d9\u28ff\u28ff\u28ff\u28ce\u28ff\u28f7\u28dd\u28f7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28e0\u287e\u28f5\u28ff\u28ff\u28cf\u28bc\u28ff\u28fd\u28ff\u287f\u28c1\u28ff\u287f\u2838\u2839\u28ab\u28ff\u28df\u28fc\u28ff\u2803\u28f5\u28ff\u287f\u28b8\u28ff\u2818\u28ff\u2818\u28ff\u284f\u28ff\u28ff\u2847\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b2\u28f8\u287f\u28f3\u28ee\u28dd\u287f\u28ff\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u28f6\u28ff\u28f7\u284e\u283b\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u287b\u28b8\u287f\u28ff\u28ff\u28fd\u28e7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u289f\u28fc\u287f\u28ef\u287f\u28ef\u28ff\u28b3\u28ff\u28ff\u2817\u284b\u2801\u2800\u2800\u2808\u2812\u28a6\u28dd\u283b\u28f7\u28ff\u287f\u289f\u2865\u28fc\u28ff\u2800\u2839\u2880\u28ff\u287f\u28ff\u28ff\u284f\u28b9\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2838\u28ba\u2847\u2847\u28ff\u28b8\u28ff\u2846\u28ed\u28db\u283f\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28fe\u28ff\u28ff\u28ff\u28ff\u287c\u2847\u28ff\u28ff\u28ff\u28ef\u28b7\u28c4\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u287e\u28e3\u287f\u288b\u287e\u280b\u28fc\u28b3\u28ff\u28bf\u281f\u2821\u2888\u28e5\u2836\u281f\u281b\u283f\u2837\u28e6\u28fd\u28f7\u28e4\u2850\u28be\u28ff\u28f7\u28ff\u288f\u28c0\u2840\u2800\u28ff\u28f7\u28ff\u28ff\u28ff\u28a8\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28b8\u283e\u2867\u28ff\u28bf\u28f8\u28ff\u28f6\u28b8\u28ff\u28ff\u2857\u2836\u28af\u28ed\u28db\u287b\u287f\u283f\u28bf\u28ff\u28ff\u28ff\u28ed\u28dd\u28fb\u2847\u28fb\u28ff\u28fb\u28bf\u28ff\u28e7\u2819\u28b7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u289f\u28f5\u281f\u2800\u28b0\u287f\u28fe\u287f\u2889\u28f6\u287e\u280b\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2809\u2819\u283f\u28ff\u28e6\u2808\u2809\u2820\u2800\u283b\u28ff\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28fa\u28df\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28b8\u28f6\u285f\u28bf\u287c\u2807\u283f\u28f1\u2818\u28ff\u28e7\u28f7\u2800\u2800\u2880\u28e9\u2847\u283f\u28df\u28c3\u28ec\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28ff\u28b8\u28ff\u28f7\u28fd\u28ff\u28ef\u2868\u283b\u28e6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28bf\u28fe\u28f7\u281f\u2801\u2800\u2800\u28fe\u28bf\u28ff\u2803\u285e\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u2844\u2800\u2800\u2800\u2800\u28e0\u28fc\u28f7\u2840\u2800\u28a3\u2800\u2818\u2803\u28ff\u28ff\u28ff\u28ff\u28ff\u28cf\u28ff\u28de\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2808\u2811\u283e\u28a7\u28fe\u285e\u28f5\u28f7\u28bb\u28ff\u283b\u28f6\u28df\u28ff\u28fd\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fd\u28ff\u2847\u28ff\u2808\u28ff\u28ff\u28ff\u28ff\u28fb\u28e7\u28b1\u28dc\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28ff\u287f\u2801\u2800\u2800\u2800\u28b8\u285f\u28ff\u2807\u2880\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2800\u2800\u28f4\u28ff\u28ff\u28ff\u28ff\u28c4\u2800\u28c3\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u28ff\u28c7\u28bf\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u2847\u28a0\u28e4\u28e4\u28c4\u2840\u28a4\u28ec\u2801\u2808\u2801\u28ff\u28fe\u28ff\u28ff\u287f\u28bf\u28fb\u289b\u28ed\u28ff\u288f\u28fc\u28ff\u28ff\u28ff\u28ff\u2807\u28ee\u28e4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28c7\u28bf\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u280b\u2800\u2800\u2800\u2800\u2880\u28ff\u28bb\u285f\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2800\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u2818\u2847\u2840\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u2838\u28ff\u28ff\u28fe\u28ff\u28ff\u28ff\u2847\u28b0\u28ec\u28dd\u287b\u28bf\u28c7\u28c2\u28ec\u28e4\u28d9\u284b\u282d\u28a5\u28c0\u28c0\u2808\u2819\u2803\u28eb\u28f7\u28ff\u28ff\u28ff\u28ff\u28ed\u28ff\u28a2\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2808\u28ff\u28ce\u28bf\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u285f\u28fe\u2881\u28fe\u28ff\u2809\u2819\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u2801\u2800\u28fc\u28ff\u28ff\u28ff\u28ff\u287f\u283f\u28ff\u28ff\u2847\u2881\u28ff\u28b9\u28ff\u28fe\u28ff\u28ff\u28ff\u2844\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u283b\u28ff\u28ff\u28f6\u28cd\u283b\u28ff\u28ff\u28ff\u28ff\u287f\u2883\u28fe\u280f\u28e0\u28f4\u28ff\u28ff\u287f\u283f\u28db\u28a9\u285c\u28bf\u288f\u28bc\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28bf\u28ff\u28ff\u2800\u28ff\u28bb\u28ce\u28ff\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28a0\u28ff\u28b9\u285f\u28f8\u28ff\u2847\u2800\u2800\u2808\u28bf\u28ff\u28ff\u287f\u280b\u2800\u2800\u28a0\u28ff\u287f\u281f\u2809\u2801\u28e0\u287e\u288b\u28ff\u28e7\u2818\u283b\u28b8\u28ff\u28cf\u28ff\u28ff\u28ff\u28e7\u2889\u28d3\u28c8\u289b\u287f\u283f\u28ff\u28e6\u28f6\u28ff\u28ff\u28ff\u283f\u28c2\u28e4\u287d\u28ff\u280f\u28f4\u287f\u288b\u28fe\u287f\u281f\u280b\u2810\u28fe\u28ff\u28ff\u2844\u283b\u28f6\u28bf\u28e3\u28ee\u28dd\u28bf\u28ff\u28ff\u28ff\u28f7\u287b\u28ff\u2800\u285f\u2808\u28ff\u285c\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u284f\u28ff\u2847\u28ff\u28ff\u2803\u2800\u2800\u2800\u2800\u2819\u2809\u2800\u2800\u2800\u2800\u2818\u2809\u2800\u28c0\u28f4\u287e\u288b\u28f5\u28ff\u28ff\u28e7\u2844\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u2819\u281b\u2893\u28d9\u28bf\u28f7\u28fe\u28ff\u28ff\u28ff\u28f5\u28fe\u28ff\u28ff\u28e7\u28c4\u28f8\u280b\u28a4\u286c\u2825\u2800\u2800\u28b6\u2844\u2808\u2839\u28ff\u28c7\u2880\u2808\u289d\u28ff\u28ff\u28ff\u28ff\u287d\u28ff\u28ff\u28ff\u287f\u2801\u28a0\u2847\u2800\u28bb\u28ff\u28f9\u28c7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u287f\u28f8\u28ff\u28f7\u2838\u28ff\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28e0\u28f4\u28fe\u281f\u28e9\u28f6\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2838\u281b\u28db\u28ef\u28e4\u28ff\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e6\u28d4\u28b6\u28f6\u28f7\u2840\u28bb\u28c4\u2800\u2819\u283f\u28ce\u2800\u2808\u283b\u28ff\u28ff\u28ff\u28ff\u28df\u28ff\u28bf\u2801\u2800\u28fc\u2800\u2800\u2818\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28a3\u28ff\u28ff\u28ff\u2847\u2803\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28c0\u28e4\u28fe\u28ff\u287f\u289b\u28e5\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28b6\u28ed\u28d9\u285b\u283f\u28bf\u28fe\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ef\u287b\u28bf\u28c0\u2819\u2802\u2800\u28c0\u2808\u2811\u2800\u2800\u2800\u28a0\u28ee\u280d\u2849\u28a0\u284c\u2801\u2800\u2807\u2880\u28e4\u2840\u28bb\u285f\u288f\u28ff\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u28cf\u28ff\u28ff\u28ff\u28ff\u28e0\u2808\u2800\u2800\u2880\u28e0\u28f4\u28fe\u28ff\u28ff\u28ff\u281f\u28cb\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2801\u2800\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2840\u2809\u285b\u28bf\u28f7\u28e6\u28ec\u28c9\u2813\u28ae\u287b\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28dd\u2826\u2800\u2802\u2819\u28f7\u28c4\u2840\u2800\u2800\u2800\u2800\u2800\u2810\u2800\u2800\u2800\u28f6\u2800\u28f8\u28ff\u2847\u2838\u28c7\u2818\u285c\u28f7\u2840\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28b0\u285f\u28fc\u28ff\u28ff\u28ff\u284f\u28ff\u28b0\u28f6\u28ff\u281b\u281b\u281b\u2809\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u2800\u2800\u2800\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28bb\u28ff\u28e7\u2800\u28b9\u28f7\u28ee\u281b\u28ff\u28ff\u28ff\u28e6\u284c\u2810\u285d\u28bb\u28ff\u28ff\u28ff\u28f6\u28fd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u2844\u2800\u28a0\u28b9\u28ff\u28ff\u28f7\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u28fc\u2803\u2800\u2818\u28ff\u2803\u2800\u28ed\u2800\u28bb\u2819\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2880\u28ff\u28b3\u28ff\u28ff\u28ff\u28ff\u28f7\u28ff\u2846\u28ff\u2803\u2800\u2800\u2800\u2800\u28e0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u2800\u28a0\u2847\u28b0\u2802\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2848\u28bf\u28ff\u2844\u2808\u28ff\u28ff\u28f7\u28e4\u2849\u281b\u283f\u28ff\u28f7\u28e6\u28d4\u2859\u283b\u28ff\u28ff\u28ff\u28ff\u28dd\u28bf\u28ff\u28ff\u28ff\u28ff\u28e6\u2840\u2888\u28bb\u28ff\u28ff\u28ff\u28c4\u28a4\u28c0\u2800\u2880\u28f4\u280f\u2800\u2800\u2800\u2800\u2800\u28fe\u28ff\u2847\u28b8\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28fe\u28af\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u28c7\u2880\u28c0\u28e4\u28f4\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u281f\u28e1\u2804\u2800\u28fc\u2847\u28ba\u2801\u28ff\u28ff\u28e7\u28ff\u28ff\u28ff\u2847\u2808\u28bf\u28ff\u2840\u28b9\u28ff\u28ff\u28ff\u28ff\u28ef\u28fb\u28f6\u28ef\u28fd\u28ff\u283f\u28ff\u28fe\u28fd\u28fb\u28bf\u28ff\u28ff\u28fd\u28ff\u28bf\u28ff\u28ff\u28ff\u28de\u28c7\u2899\u28bf\u28ff\u28ff\u28ef\u28fb\u28ff\u28f6\u28e4\u28c0\u2800\u2800\u2800\u2800\u2800\u28bb\u287f\u2801\u2808\u28ff\u28e7\u28bb\u28c6\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f8\u28cf\u28ff\u28ff\u28ff\u28ff\u28bf\u28ff\u28ff\u287f\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u280f\u2801\u28b9\u28ff\u28ff\u28ff\u28ff\u285f\u2800\u28f8\u28ff\u2847\u2808\u2800\u28ff\u28ff\u28ff\u28ff\u284f\u28ff\u28e7\u2800\u2800\u283b\u28f7\u285c\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u287b\u28ff\u28ff\u28f7\u28ff\u28fd\u28db\u287f\u28be\u28fd\u28db\u28bf\u28ff\u28fe\u28dd\u287f\u28ff\u28ef\u2843\u28a3\u287b\u28ff\u28ff\u28f7\u287d\u28ff\u28ff\u28ff\u28f7\u28e6\u28c0\u2800\u2800\u2800\u2800\u2800\u2800\u28bf\u28ff\u28e7\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28ff\u28ff\u28af\u28ff\u288f\u28fe\u28ff\u28ff\u2883\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2801\u2800\u28e0\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28f8\u28ff\u28ff\u2847\u2800\u2800\u28ff\u28ff\u28b9\u28ff\u2847\u28bb\u28ff\u2800\u2800\u28a0\u284c\u283f\u28dc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28f6\u28ef\u28dd\u28ca\u283d\u289b\u283f\u28f7\u28dd\u28b7\u28e4\u2833\u285c\u28bf\u28ff\u28ff\u28dd\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u2800\u2800\u2800\u28b8\u28ff\u28ff\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u285f\u28fe\u28ff\u28ef\u28ff\u28a3\u28fe\u28ff\u28ff\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2800\u28e0\u28fc\u28ff\u28ff\u28ff\u28ff\u28ff\u2803\u28f0\u28ff\u28ff\u28ff\u2847\u2844\u2800\u28ff\u28ff\u28b8\u28ff\u2847\u2808\u28bf\u2847\u2800\u28ff\u2807\u28f7\u28ef\u28c3\u28d9\u28db\u28db\u28f5\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28dd\u287b\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28ee\u28c4\u2848\u2833\u289c\u2803\u2818\u288e\u28bf\u28ff\u28ff\u28de\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c6\u2840\u2800\u2818\u28ff\u285f\u28b8\u2847\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f0\u28df\u28fd\u28ff\u28ef\u28ff\u28e3\u28ff\u28ff\u28ff\u28ff\u28ff\u2844\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u281f\u28e1\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u284f\u28f0\u28ff\u28ff\u287f\u28b9\u2847\u28fe\u2800\u28ff\u28ff\u28fe\u28ff\u2847\u2800\u2808\u28b7\u28b0\u28ff\u28b8\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28fd\u28fb\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28e6\u28e6\u28c0\u2840\u2808\u281b\u283b\u28bf\u28ce\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28f7\u28c4\u2800\u28ff\u2847\u2818\u28e7\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u28f4\u289f\u28fe\u28ff\u28fb\u287f\u28f5\u28ff\u28ff\u28ff\u28ff\u28df\u28fe\u28ef\u280c\u281b\u281b\u281b\u281b\u2809\u2801\u28a0\u28fe\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u287f\u280f\u28f0\u28ff\u28ff\u287f\u2801\u28b8\u2847\u28bf\u2800\u28ff\u28ff\u28ff\u28ff\u2847\u2800\u2820\u2808\u2802\u2883\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28fe\u28ff\u28fb\u287f\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u2847\u2844\u283e\u28f2\u28fe\u28ff\u28cc\u28bf\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28ff\u28e7\u2859\u2807\u2800\u28bb\u2844\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u2800\u281b\u281a\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u2813\u281a\u2812\u281b\u2812\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u2813\u281a\u281b\u281b\u2813\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281a\u281b\u281b\u281b\u281b\u281b\u281b\u2813\u281b\u281a\u281b\u281b\u281b\u281b\u2813\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u281b\u2812\u281a\u281a\u2801\u2800\u2800\u2800\u2800\u2800\u2800\u2800\n+\"\"\"\n+] = {\"en\": \":a1:\", \"status\": 2, \"E\": 0.6, \"alias\": [\":a1:\"]}\n+\n+\n def set_link_target_blank(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):element.attrs['target']='_blank';element.attrs['rel']='noopener noreferrer';element.attrs['referrerpolicy']='no-referrer';element.attrs[_A]=element.attrs[_A].strip('.').strip(',')\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+\n+    for element in soup.find_all(\"a\"):\n+        element.attrs[\"target\"] = \"_blank\"\n+        element.attrs[\"rel\"] = \"noopener noreferrer\"\n+        element.attrs[\"referrerpolicy\"] = \"no-referrer\"\n+        element.attrs[\"href\"] = element.attrs[\"href\"].strip(\".\").strip(\",\")\n+\n+    return str(soup)\n+\n+\n def embed_youtube(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\t\tvideo_name=element.attrs[_A].split('/')[-1]\n-\t\t\tif'v='in element.attrs[_A]:video_name=element.attrs[_A].split('?v=')[1].split('&')[0]\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+            video_name = element.attrs[\"href\"].split(\"/\")[-1]\n+            if \"v=\" in element.attrs[\"href\"]:\n+                video_name = element.attrs[\"href\"].split(\"?v=\")[1].split(\"&\")[0]\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def embed_image(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\tfor extension in['.png','.jpg','.jpeg','.gif','.webp','.svg','.bmp','.tiff','.ico','.heif']:\n-\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<img src=\"{element.attrs[_A]}\" title=\"{element.attrs[_A]}\" alt=\"{element.attrs[_A]}\" />';element.replace_with(BeautifulSoup(embed_template,_B))\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".png\",\n+            \".jpg\",\n+            \".jpeg\",\n+            \".gif\",\n+            \".webp\",\n+            \".svg\",\n+            \".bmp\",\n+            \".tiff\",\n+            \".ico\",\n+            \".heif\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n+                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def embed_media(text):\n-\tsoup=BeautifulSoup(text,_B)\n-\tfor element in soup.find_all('a'):\n-\t\tfor extension in['.mp4','.mp3','.wav','.ogg','.webm','.flac','.aac','.mpg','.avi','.wmv']:\n-\t\t\tif extension in element.attrs[_A].lower():embed_template=f'<video controls> <source src=\"{element.attrs[_A]}\">Your browser does not support the video tag.</video>';element.replace_with(BeautifulSoup(embed_template,_B))\n-\treturn str(soup)\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"a\"):\n+        for extension in [\n+            \".mp4\",\n+            \".mp3\",\n+            \".wav\",\n+            \".ogg\",\n+            \".webm\",\n+            \".flac\",\n+            \".aac\",\n+            \".mpg\",\n+            \".avi\",\n+            \".wmv\",\n+        ]:\n+            if extension in element.attrs[\"href\"].lower():\n+                embed_template = f'<video controls> <source src=\"{element.attrs[\"href\"]}\">Your browser does not support the video tag.</video>'\n+                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+\n def linkify_https(text):\n-\tfor element in soup.find_all(text=_C):\n-\t\tparent=element.parent\n-\t\tif parent.name in['a','script','style']:continue\n-\t\tnew_text=re.sub(url_pattern,'<a href=\"\\\\g<0>\">\\\\g<0></a>',element);element.replace_with(BeautifulSoup(new_text,_B))\n-\treturn set_link_target_blank(str(soup))\n+        return text\n+\n+    soup = BeautifulSoup(text, \"html.parser\")\n+\n+    for element in soup.find_all(text=True):\n+        parent = element.parent\n+        if parent.name in [\"a\", \"script\", \"style\"]:\n+            continue\n+\n+        new_text = re.sub(url_pattern, r'<a href=\"\\g<0>\">\\g<0></a>', element)\n+        element.replace_with(BeautifulSoup(new_text, \"html.parser\"))\n+\n+    return set_link_target_blank(str(soup))\n+\n+\n class EmojiExtension(Extension):\n-\ttags={'emoji'}\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endemoji'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):return emoji.emojize(caller(),language=_D)\n+    tags = {\"emoji\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endemoji\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        return emoji.emojize(caller(), language=\"alias\")\n+\n+\n class LinkifyExtension(Extension):\n-\ttags={'linkify'}\n-\tdef __init__(self,environment):self.app=SimpleNamespace(jinja2_env=environment);super(LinkifyExtension,self).__init__(environment)\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endlinkify'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):result=linkify_https(caller());result=embed_media(result);result=embed_image(result);result=embed_youtube(result);return result\n+    tags = {\"linkify\"}\n+\n+    def __init__(self, environment):\n+        self.app = SimpleNamespace(jinja2_env=environment)\n+        super(LinkifyExtension, self).__init__(environment)\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endlinkify\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+        result = linkify_https(caller())\n+        result = embed_media(result)\n+        result = embed_image(result)\n+        result = embed_youtube(result)\n+        return result\n+\n+\n class PythonExtension(Extension):\n-\ttags={'py3'}\n-\tdef parse(self,parser):\n-\t\tline_number=next(parser.stream).lineno;md_file=[Const('')];body=''\n-\t\ttry:md_file=[parser.parse_expression()]\n-\t\texcept TemplateSyntaxError:body=parser.parse_statements(['name:endpy3'],drop_needle=_C)\n-\t\treturn nodes.CallBlock(self.call_method(_E,md_file),[],[],body).set_lineno(line_number)\n-\tdef _to_html(self,md_file,caller):\n-\t\tdef fn(source):\n-\t\t\timport subprocess\n-\t\t\tdef system(command):\n-\t\t\t\tif isinstance(command):command=command.split(' ')\n-\t\t\t\tfrom io import StringIO;stdout=StringIO();subprocess.run(command,stderr=stdout,stdout=stdout,text=_C);return stdout.getvalue()\n-\t\t\tto_write=[]\n-\t\t\tdef render(text):global to_write;to_write.append(text)\n-\t\t\texec(source);return''.join(to_write)\n-\t\treturn str(fn(caller()))\n\\ No newline at end of file\n+    tags = {\"py3\"}\n+\n+    def parse(self, parser):\n+        line_number = next(parser.stream).lineno\n+        md_file = [Const(\"\")]\n+        body = \"\"\n+        try:\n+            md_file = [parser.parse_expression()]\n+        except TemplateSyntaxError:\n+            body = parser.parse_statements([\"name:endpy3\"], drop_needle=True)\n+        return nodes.CallBlock(\n+            self.call_method(\"_to_html\", md_file), [], [], body\n+        ).set_lineno(line_number)\n+\n+    def _to_html(self, md_file, caller):\n+\n+        def fn(source):\n+            import subprocess\n+\n+            def system(command):\n+                if isinstance(command):\n+                    command = command.split(\" \")\n+                from io import StringIO\n+\n+                stdout = StringIO()\n+                subprocess.run(command, stderr=stdout, stdout=stdout, text=True)\n+                return stdout.getvalue()\n+\n+            to_write = []\n+\n+            def render(text):\n+                global to_write\n+                to_write.append(text)\n+\n+            exec(source)\n+            return \"\".join(to_write)\n+\n+        return str(fn(caller()))\ndiff --git a/src/snek/system/terminal.py b/src/snek/system/terminal.py\nindex 82207c7..c5410b6 100644\n--- a/src/snek/system/terminal.py\n+++ b/src/snek/system/terminal.py\n@@ -1,49 +1,113 @@\n-_A=None\n-import asyncio,os\n-try:import pty\n-except Exception as ex:print('You are not able to run a terminal. See error:');print(ex)\n+import asyncio\n+import os\n+\n+try:\n+    import pty\n+except Exception as ex:\n+    print(\"You are not able to run a terminal. See error:\")\n+    print(ex)\n import subprocess\n-commands={'alpine':'docker run -it alpine /bin/sh','r':'docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh'}\n+\n+commands = {\n+    \"alpine\": \"docker run -it alpine /bin/sh\",\n+    \"r\": \"docker run -v /usr/local/bin:/usr/local/bin -it ubuntu:latest run.sh\",\n+}\n+\n+\n class TerminalSession:\n-\tdef __init__(A,command):A.master,A.slave=_A,_A;A.process=_A;A.sockets=[];A.history=b'';A.history_size=20480;A.command=command;A.start_process(A.command)\n-\tdef start_process(A,command):\n-\t\tif not A.is_running():\n-\t\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n-\t\t\tA.master,A.slave=pty.openpty();A.process=subprocess.Popen(command.split(' '),stdin=A.slave,stdout=A.slave,stderr=A.slave,bufsize=0,universal_newlines=True)\n-\tdef is_running(A):\n-\t\tif not A.process:return False\n-\t\tasyncio.get_event_loop();return A.process.poll()is _A\n-\tasync def add_websocket(A,ws):A.start_process(A.command);asyncio.create_task(A.read_output(ws))\n-\tasync def read_output(A,ws):\n-\t\tB=ws;A.sockets.append(B)\n-\t\tif len(A.sockets)>1 and A.history:\n-\t\t\tD=0\n-\t\t\ttry:D=A.history.index(b'\\n')\n-\t\t\texcept ValueError:pass\n-\t\t\tawait B.send_bytes(A.history[D:]);return\n-\t\tE=asyncio.get_event_loop()\n-\t\twhile True:\n-\t\t\ttry:\n-\t\t\t\tC=await E.run_in_executor(_A,os.read,A.master,1024)\n-\t\t\t\tif not C:break\n-\t\t\t\tA.history+=C\n-\t\t\t\tif len(A.history)>A.history_size:A.history=A.history[:0-A.history_size]\n-\t\t\t\ttry:\n-\t\t\t\t\tfor B in A.sockets:await B.send_bytes(C)\n-\t\t\t\texcept:A.sockets.remove(B)\n-\t\t\texcept Exception:await A.close();break\n-\tasync def close(A):\n-\t\tprint('Terminating process')\n-\t\tif A.process:A.process.terminate();A.process=_A\n-\t\tif A.master:os.close(A.master);os.close(A.slave);A.master=_A;A.slave=_A\n-\t\tprint('Terminated process')\n-\t\tfor B in A.sockets:\n-\t\t\ttry:await B.close()\n-\t\t\texcept Exception:pass\n-\t\tA.sockets=[]\n-\tasync def write_input(B,data):\n-\t\tA=data\n-\t\ttry:A=A.encode()\n-\t\texcept AttributeError:pass\n-\t\ttry:await asyncio.get_event_loop().run_in_executor(_A,os.write,B.master,A)\n-\t\texcept Exception as C:print(C);await B.close()\n\\ No newline at end of file\n+    def __init__(self, command):\n+        self.master, self.slave = None, None\n+        self.process = None\n+        self.sockets = []\n+        self.history = b\"\"\n+        self.history_size = 1024 * 20\n+        self.command = command\n+        self.start_process(self.command)\n+\n+    def start_process(self, command):\n+        if not self.is_running():\n+            if self.master:\n+                os.close(self.master)\n+                os.close(self.slave)\n+                self.master = None\n+                self.slave = None\n+\n+            self.master, self.slave = pty.openpty()\n+            self.process = subprocess.Popen(\n+                command.split(\" \"),\n+                stdin=self.slave,\n+                stdout=self.slave,\n+                stderr=self.slave,\n+                bufsize=0,\n+                universal_newlines=True,\n+            )\n+\n+    def is_running(self):\n+        if not self.process:\n+            return False\n+        asyncio.get_event_loop()\n+        return self.process.poll() is None\n+\n+    async def add_websocket(self, ws):\n+        self.start_process(self.command)\n+        asyncio.create_task(self.read_output(ws))\n+\n+    async def read_output(self, ws):\n+        self.sockets.append(ws)\n+        if len(self.sockets) > 1 and self.history:\n+            start = 0\n+            try:\n+                start = self.history.index(b\"\\n\")\n+            except ValueError:\n+                pass\n+            await ws.send_bytes(self.history[start:])\n+            return\n+        loop = asyncio.get_event_loop()\n+        while True:\n+            try:\n+                data = await loop.run_in_executor(None, os.read, self.master, 1024)\n+                if not data:\n+                    break\n+                self.history += data\n+                if len(self.history) > self.history_size:\n+                    self.history = self.history[: 0 - self.history_size]\n+                try:\n+                    for ws in self.sockets:\n+                except:\n+                    self.sockets.remove(ws)\n+            except Exception:\n+                await self.close()\n+                break\n+\n+    async def close(self):\n+        print(\"Terminating process\")\n+        if self.process:\n+            self.process.terminate()\n+            self.process = None\n+        if self.master:\n+            os.close(self.master)\n+            os.close(self.slave)\n+            self.master = None\n+            self.slave = None\n+\n+        print(\"Terminated process\")\n+        for ws in self.sockets:\n+            try:\n+                await ws.close()\n+            except Exception:\n+                pass\n+        self.sockets = []\n+\n+    async def write_input(self, data):\n+        try:\n+            data = data.encode()\n+        except AttributeError:\n+            pass\n+        try:\n+            await asyncio.get_event_loop().run_in_executor(\n+                None, os.write, self.master, data\n+            )\n+        except Exception as ex:\n+            print(ex)\n+            await self.close()\ndiff --git a/src/snek/system/view.py b/src/snek/system/view.py\nindex be19178..70379ef 100644\n--- a/src/snek/system/view.py\n+++ b/src/snek/system/view.py\n@@ -1,31 +1,75 @@\n from aiohttp import web\n+\n from snek.system.markdown import render_markdown\n+\n+\n class BaseView(web.View):\n-\tlogin_required=False\n-\tasync def _iter(A):\n-\t\tif A.login_required and(not A.session.get('logged_in')or not A.session.get('uid')):return web.HTTPFound('/')\n-\t\treturn await super()._iter()\n-\t@property\n-\tdef base_url(self):return str(self.request.url.with_path('').with_query(''))\n-\t@property\n-\tdef app(self):return self.request.app\n-\t@property\n-\tdef db(self):return self.app.db\n-\t@property\n-\tdef services(self):return self.app.services\n-\tasync def json_response(B,data,**A):return web.json_response(data,**A)\n-\t@property\n-\tdef session(self):return self.request.session\n-\tasync def render_template(A,template_name,context=None):\n-\t\tC=context;B=template_name\n-\t\tif B.endswith('.md'):D=await A.request.app.render_template(B,A.request,C);E=await render_markdown(A.app,D.body.decode());return web.Response(body=E,content_type='text/html')\n-\t\treturn await A.request.app.render_template(B,A.request,C)\n+\n+    login_required = False\n+\n+    async def _iter(self):\n+        if self.login_required and (\n+            not self.session.get(\"logged_in\") or not self.session.get(\"uid\")\n+        ):\n+            return web.HTTPFound(\"/\")\n+        return await super()._iter()\n+\n+    @property\n+    def base_url(self):\n+        return str(self.request.url.with_path(\"\").with_query(\"\"))\n+\n+    @property\n+    def app(self):\n+        return self.request.app\n+\n+    @property\n+    def db(self):\n+        return self.app.db\n+\n+    @property\n+    def services(self):\n+        return self.app.services\n+\n+    async def json_response(self, data, **kwargs):\n+        return web.json_response(data, **kwargs)\n+\n+    @property\n+    def session(self):\n+        return self.request.session\n+\n+    async def render_template(self, template_name, context=None):\n+        if template_name.endswith(\".md\"):\n+            response = await self.request.app.render_template(\n+                template_name, self.request, context\n+            )\n+            body = await render_markdown(self.app, response.body.decode())\n+            return web.Response(body=body, content_type=\"text/html\")\n+        return await self.request.app.render_template(\n+            template_name, self.request, context\n+        )\n+\n+\n class BaseFormView(BaseView):\n-\tform=None\n-\tasync def get(A):B=A.form(app=A.app);return await A.json_response(await B.to_json())\n-\tasync def post(A):\n-\t\tE='action';C=A.form(app=A.app);D=await A.request.json();C.set_user_data(D['form']);B=await C.to_json()\n-\t\tif D.get(E)=='validate':0\n-\t\tif D.get(E)=='submit'and B['is_valid']:B=await A.submit(C);return await A.json_response(B)\n-\t\treturn await A.json_response(B)\n-\tasync def submit(A,model=None):0\n\\ No newline at end of file\n+\n+    form = None\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+\n+        return await self.json_response(await form.to_json())\n+\n+    async def post(self):\n+        form = self.form(app=self.app)\n+        post = await self.request.json()\n+        form.set_user_data(post[\"form\"])\n+        result = await form.to_json()\n+        if post.get(\"action\") == \"validate\":\n+            pass\n+        if post.get(\"action\") == \"submit\" and result[\"is_valid\"]:\n+            result = await self.submit(form)\n+            return await self.json_response(result)\n+        return await self.json_response(result)\n+\n+    async def submit(self, model=None):\n+        pass\ndiff --git a/src/snek/view/about.py b/src/snek/view/about.py\nindex 740ec7a..aba57ae 100644\n--- a/src/snek/view/about.py\n+++ b/src/snek/view/about.py\n@@ -1,5 +1,39 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class AboutHTMLView(BaseView):\n-\tasync def get(A):return await A.render_template('about.html')\n+\n+    async def get(self):\n+        return await self.render_template(\"about.html\")\n+\n+\n class AboutMDView(BaseView):\n-\tasync def get(A):return await A.render_template('about.md')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        return await self.render_template(\"about.md\")\ndiff --git a/src/snek/view/avatar.py b/src/snek/view/avatar.py\nindex c95384a..a85b876 100644\n--- a/src/snek/view/avatar.py\n+++ b/src/snek/view/avatar.py\n@@ -1,10 +1,44 @@\n+\n+\n+\n import uuid\n+\n from aiohttp import web\n from multiavatar import multiavatar\n+\n from snek.system.view import BaseView\n+\n+\n class AvatarView(BaseView):\n-\tlogin_required=False\n-\tasync def get(C):\n-\t\tA=C.request.match_info.get('uid')\n-\t\tif A=='unique':A=str(uuid.uuid4())\n-\t\tD=multiavatar.multiavatar(A,True,None);B=web.Response(text=D,content_type='image/svg+xml');B.headers['Cache-Control']=f\"public, max-age={56154}\";return B\n\\ No newline at end of file\n+    login_required = False\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        if uid == \"unique\":\n+            uid = str(uuid.uuid4())\n+        avatar = multiavatar.multiavatar(uid, True, None)\n+        response = web.Response(text=avatar, content_type=\"image/svg+xml\")\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*42}\"\n+        return response\ndiff --git a/src/snek/view/docs.py b/src/snek/view/docs.py\nindex ce1e31c..bb63413 100644\n--- a/src/snek/view/docs.py\n+++ b/src/snek/view/docs.py\n@@ -1,5 +1,37 @@\n+\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class DocsHTMLView(BaseView):\n-\tasync def get(A):return await A.render_template('docs.html')\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.html\")\n+\n+\n class DocsMDView(BaseView):\n-\tasync def get(A):return await A.render_template('docs.md')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        return await self.render_template(\"docs.md\")\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex 630cc3a..e3c3343 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -1,99 +1,269 @@\n-_P='Path not found'\n-_O='application/octet-stream'\n-_N='items'\n-_M='size'\n-_L='mimetype'\n-_K='name'\n-_J='rel_path'\n-_I='dir'\n-_H='url'\n-_G=None\n-_F='path'\n-_E='status'\n-_D='file'\n-_C='uid'\n-_B='absolute_url'\n-_A='type'\n from aiohttp import web\n+\n from snek.system.view import BaseView\n-import os,mimetypes\n+\n+\n+import os\n+import mimetypes\n from aiohttp import web\n-from urllib.parse import unquote,quote\n+from urllib.parse import unquote, quote\n from datetime import datetime\n+\n+\n+\n+\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n+\"\"\"\n from aiohttp import web\n from pathlib import Path\n-import mimetypes,urllib.parse\n-BASE_DIR=Path(__file__).parent.resolve()\n-ROOT_DIR=(BASE_DIR/'storage').resolve()\n-ASSETS_DIR=(BASE_DIR/'assets').resolve()\n+import mimetypes, urllib.parse\n+\n+BASE_DIR   = Path(__file__).parent.resolve()\n ROOT_DIR.mkdir(exist_ok=True)\n ASSETS_DIR.mkdir(exist_ok=True)\n-def safe_resolve_path(rel):\n-\t'Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.';A=(ROOT_DIR/rel.lstrip('/')).resolve()\n-\tif A==ROOT_DIR or ROOT_DIR in A.parents:return A\n-\traise FileNotFoundError('Unsafe path')\n+\n+\n+def safe_resolve_path(rel: str) -> Path:\n+    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n+    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n+    if target == ROOT_DIR or ROOT_DIR in target.parents:\n+        return target\n+    raise FileNotFoundError(\"Unsafe path\")\n+\n+\n class DriveView(BaseView):\n-\tasync def get(C):\n-\t\tH='limit';I='offset';D=C.request.query.get(_F,'');E=int(C.request.query.get(I,0));J=int(C.request.query.get(H,20));A=await C.services.user.get_home_folder(C.session.get(_C))\n-\t\tif D:A.joinpath(D)\n-\t\tif not A.exists():return web.json_response({'error':'Not found'},status=404)\n-\t\tif A.is_dir():\n-\t\t\tF=[]\n-\t\t\tfor B in sorted(A.iterdir(),key=lambda p:(p.is_file(),p.name.lower())):K=(Path(D)/B.name).as_posix();M=mimetypes.guess_type(B.name)[0]if B.is_file()else'inode/directory';G=C.request.url.with_path(f\"/drive/{urllib.parse.quote(K)}\")if B.is_file()else _G;F.append({_K:B.name,_A:'directory'if B.is_dir()else _D,_L:M,_M:B.stat().st_size if B.is_file()else _G,_F:K,_H:G})\n-\t\t\timport json as L;N=len(F);O=F[E:E+J];return web.json_response({_N:L.loads(L.dumps(O,default=str)),'pagination':{I:E,H:J,'total':N}})\n-\t\twith open(A,'rb')as P:Q=P.read();return web.Response(body=Q,content_type=mimetypes.guess_type(A.name)[0])\n-\t\tG=C.request.url.with_path(f\"/drive/{urllib.parse.quote(D)}\");return web.json_response({_K:A.name,_A:_D,_L:mimetypes.guess_type(A.name)[0],_M:A.stat().st_size,_F:D,_H:str(G)})\n+    async def get(self):\n+        rel = self.request.query.get(\"path\", \"\")\n+        offset = int(self.request.query.get(\"offset\", 0))\n+        limit  = int(self.request.query.get(\"limit\", 20))\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        if rel:\n+            target.joinpath(rel)\n+\n+        if not target.exists():\n+            return web.json_response({\"error\": \"Not found\"}, status=404)\n+\n+        if target.is_dir():\n+            entries = []\n+            for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n+                item_path = (Path(rel) / p.name).as_posix()\n+                mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n+                url  = (self.request.url.with_path(f\"/drive/{urllib.parse.quote(item_path)}\")\n+                        if p.is_file() else None)\n+                entries.append({\n+                    \"name\": p.name,\n+                    \"type\": \"directory\" if p.is_dir() else \"file\",\n+                    \"mimetype\": mime,\n+                    \"size\": p.stat().st_size if p.is_file() else None,\n+                    \"path\": item_path,\n+                    \"url\": url,\n+                })\n+            import json \n+            total = len(entries)\n+            items = entries[offset:offset+limit]\n+            return web.json_response({\n+                \"items\": json.loads(json.dumps(items,default=str)),\n+                \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n+            })\n+        \n+        with open(target, \"rb\") as f:\n+            content = f.read()\n+            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n+        url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n+        return web.json_response({\n+            \"name\": target.name,\n+            \"type\": \"file\",\n+            \"mimetype\": mimetypes.guess_type(target.name)[0],\n+            \"size\": target.stat().st_size,\n+            \"path\": rel,\n+            \"url\": str(url),\n+        })\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n+\n class DriveView222(BaseView):\n-\tPAGE_SIZE=20\n-\tasync def base_path(A):return await A.services.user.get_home_folder(A.session.get(_C))\n-\tasync def get_full_path(C,rel_path):\n-\t\tA=await C.base_path();D=os.path.normpath(unquote(rel_path or''));B=os.path.abspath(os.path.join(A,D))\n-\t\tif not B.startswith(os.path.abspath(A)):raise web.HTTPForbidden(reason='Invalid path')\n-\t\treturn B\n-\tasync def make_absolute_url(B,rel_path):A=rel_path;A=A.lstrip('/');C=str(B.request.url.with_path(f\"/drive/{quote(A)}\"));return C\n-\tasync def entry_details(E,dir_path,entry,parent_rel_path):A=entry;B=os.path.join(dir_path,A);C=os.stat(B);D=os.path.isdir(B);F=_G if D else mimetypes.guess_type(B)[0]or _O;G=C.st_size if not D else _G;H=datetime.fromtimestamp(C.st_ctime).isoformat();I=datetime.fromtimestamp(C.st_mtime).isoformat();J=os.path.join(parent_rel_path,A).replace('\\\\','/');return{_K:A,_A:_I if D else _D,_L:F,_M:G,'created_at':H,'updated_at':I,_B:await E.make_absolute_url(J)}\n-\tasync def get(A):\n-\t\tF='page_size';G='page';C=A.request.match_info.get(_J,'');B=await A.get_full_path(C);H=int(A.request.query.get(G,1));D=int(A.request.query.get(F,A.PAGE_SIZE));I=await A.make_absolute_url(C)\n-\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason=_P)\n-\t\tif os.path.isdir(B):E=os.listdir(B);E.sort();J=(H-1)*D;K=J+D;L=E[J:K];M=[await A.entry_details(B,D,C)for D in L];return web.json_response({_F:C,_B:I,'entries':M,'total':len(E),G:H,F:D})\n-\t\telse:\n-\t\t\twith open(B,'rb')as N:O=N.read()\n-\t\t\tP=mimetypes.guess_type(B)[0]or _O;Q={'X-Absolute-Url':I};return web.Response(body=O,content_type=P,headers=Q)\n-\tasync def post(A):\n-\t\tC='created';D=A.request.match_info.get(_J,'');B=await A.get_full_path(D);E=await A.make_absolute_url(D)\n-\t\tif os.path.exists(B):raise web.HTTPConflict(reason='File or directory already exists')\n-\t\tF=await A.request.post()\n-\t\tif F.get(_A)==_I:os.makedirs(B);return web.json_response({_E:C,_A:_I,_B:E})\n-\t\telse:\n-\t\t\tG=F.get(_D)\n-\t\t\tif not G:raise web.HTTPBadRequest(reason='No file uploaded')\n-\t\t\twith open(B,'wb')as H:H.write(G.file.read())\n-\t\t\treturn web.json_response({_E:C,_A:_D,_B:E})\n-\tasync def put(A):\n-\t\tC=A.request.match_info.get(_J,'');B=await A.get_full_path(C);D=await A.make_absolute_url(C)\n-\t\tif not os.path.exists(B):raise web.HTTPNotFound(reason='File not found')\n-\t\tif os.path.isdir(B):raise web.HTTPBadRequest(reason='Cannot overwrite directory')\n-\t\tE=await A.request.read()\n-\t\twith open(B,'wb')as F:F.write(E)\n-\t\treturn web.json_response({_E:'updated',_B:D})\n-\tasync def delete(B):\n-\t\tC='deleted';D=B.request.match_info.get(_J,'');A=await B.get_full_path(D);E=await B.make_absolute_url(D)\n-\t\tif not os.path.exists(A):raise web.HTTPNotFound(reason=_P)\n-\t\tif os.path.isdir(A):os.rmdir(A);return web.json_response({_E:C,_A:_I,_B:E})\n-\t\telse:os.remove(A);return web.json_response({_E:C,_A:_D,_B:E})\n+    PAGE_SIZE = 20\n+\n+    async def base_path(self):\n+        return await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n+    async def get_full_path(self, rel_path):\n+        base_path = await self.base_path()\n+        safe_path = os.path.normpath(unquote(rel_path or \"\"))\n+        full_path = os.path.abspath(os.path.join(base_path, safe_path))\n+        if not full_path.startswith(os.path.abspath(base_path)):\n+            raise web.HTTPForbidden(reason=\"Invalid path\")\n+        return full_path\n+\n+    async def make_absolute_url(self, rel_path):\n+        rel_path = rel_path.lstrip(\"/\")\n+        url = str(self.request.url.with_path(f\"/drive/{quote(rel_path)}\"))\n+        return url\n+\n+    async def entry_details(self, dir_path, entry, parent_rel_path):\n+        entry_path = os.path.join(dir_path, entry)\n+        stat = os.stat(entry_path)\n+        is_dir = os.path.isdir(entry_path)\n+        mimetype = None if is_dir else (mimetypes.guess_type(entry_path)[0] or \"application/octet-stream\")\n+        size = stat.st_size if not is_dir else None\n+        created_at = datetime.fromtimestamp(stat.st_ctime).isoformat()\n+        updated_at = datetime.fromtimestamp(stat.st_mtime).isoformat()\n+        rel_entry_path = os.path.join(parent_rel_path, entry).replace(\"\\\\\", \"/\")\n+        return {\n+            \"name\": entry,\n+            \"type\": \"dir\" if is_dir else \"file\",\n+            \"mimetype\": mimetype,\n+            \"size\": size,\n+            \"created_at\": created_at,\n+            \"updated_at\": updated_at,\n+            \"absolute_url\": await self.make_absolute_url(rel_entry_path),\n+        }\n+\n+    async def get(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        page = int(self.request.query.get(\"page\", 1))\n+        page_size = int(self.request.query.get(\"page_size\", self.PAGE_SIZE))\n+        abs_url = await self.make_absolute_url(rel_path)\n+\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+\n+        if os.path.isdir(full_path):\n+            entries = os.listdir(full_path)\n+            entries.sort()\n+            start = (page - 1) * page_size\n+            end = start + page_size\n+            paged_entries = entries[start:end]\n+            details = [await self.entry_details(full_path, entry, rel_path) for entry in paged_entries]\n+            return web.json_response({\n+                \"path\": rel_path,\n+                \"absolute_url\": abs_url,\n+                \"entries\": details,\n+                \"total\": len(entries),\n+                \"page\": page,\n+                \"page_size\": page_size,\n+            })\n+        else:\n+            with open(full_path, \"rb\") as f:\n+                content = f.read()\n+            mimetype = mimetypes.guess_type(full_path)[0] or \"application/octet-stream\"\n+            headers = {\"X-Absolute-Url\": abs_url}\n+            return web.Response(body=content, content_type=mimetype, headers=headers)\n+\n+    async def post(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if os.path.exists(full_path):\n+            raise web.HTTPConflict(reason=\"File or directory already exists\")\n+        data = await self.request.post()\n+        if data.get(\"type\") == \"dir\":\n+            os.makedirs(full_path)\n+            return web.json_response({\"status\": \"created\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            file_field = data.get(\"file\")\n+            if not file_field:\n+                raise web.HTTPBadRequest(reason=\"No file uploaded\")\n+            with open(full_path, \"wb\") as f:\n+                f.write(file_field.file.read())\n+            return web.json_response({\"status\": \"created\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+    async def put(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"File not found\")\n+        if os.path.isdir(full_path):\n+            raise web.HTTPBadRequest(reason=\"Cannot overwrite directory\")\n+        body = await self.request.read()\n+        with open(full_path, \"wb\") as f:\n+            f.write(body)\n+        return web.json_response({\"status\": \"updated\", \"absolute_url\": abs_url})\n+\n+    async def delete(self):\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        full_path = await self.get_full_path(rel_path)\n+        abs_url = await self.make_absolute_url(rel_path)\n+        if not os.path.exists(full_path):\n+            raise web.HTTPNotFound(reason=\"Path not found\")\n+        if os.path.isdir(full_path):\n+            os.rmdir(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"dir\", \"absolute_url\": abs_url})\n+        else:\n+            os.remove(full_path)\n+            return web.json_response({\"status\": \"deleted\", \"type\": \"file\", \"absolute_url\": abs_url})\n+\n+\n class DriveViewi2(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):\n-\t\tG='/drive.bin/';D=A.request.match_info.get('drive');H=A.request.query.get('before');E={}\n-\t\tif H:E['created_at__lt']=H\n-\t\tif D:\n-\t\t\tE['drive_uid']=D;F=await A.services.drive.get(uid=D);I=[]\n-\t\t\tasync for C in A.services.drive_item.find(**E):B=C.record;B[_H]=G+B[_C]+'.'+C.extension;I.append(B)\n-\t\t\treturn web.json_response(I)\n-\t\tL=await A.services.user.get(uid=A.session.get(_C));J=[]\n-\t\tasync for F in A.services.drive.get_by_user(L[_C]):\n-\t\t\tB=F.record;B[_N]=[]\n-\t\t\tasync for C in F.items:K=C.record;K[_H]=G+K[_C]+'.'+C.extension;B[_N].append(C.record)\n-\t\t\tJ.append(B)\n-\t\treturn web.json_response(J)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        drive_uid = self.request.match_info.get(\"drive\")\n+            \n+\n+        before = self.request.query.get(\"before\")\n+        filters = {} \n+        if before:\n+            filters[\"created_at__lt\"] = before\n+\n+        if drive_uid:\n+            filters['drive_uid'] = drive_uid \n+            drive = await self.services.drive.get(uid=drive_uid)\n+            drive_items = []\n+            \n+            \n+            \n+            async for item in self.services.drive_item.find(**filters):\n+                record = item.record\n+                record[\"url\"] = \"/drive.bin/\" + record[\"uid\"] + \".\" + item.extension\n+                drive_items.append(record)\n+            return web.json_response(drive_items)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        drives = []\n+        async for drive in self.services.drive.get_by_user(user[\"uid\"]):\n+            record = drive.record\n+            record[\"items\"] = []\n+            async for item in drive.items:\n+                drive_item_record = item.record\n+                drive_item_record[\"url\"] = (\n+                    \"/drive.bin/\" + drive_item_record[\"uid\"] + \".\" + item.extension\n+                )\n+                record[\"items\"].append(item.record)\n+            drives.append(record)\n+\n+        return web.json_response(drives)\ndiff --git a/src/snek/view/index.py b/src/snek/view/index.py\nindex 2bd3245..2f44443 100644\n--- a/src/snek/view/index.py\n+++ b/src/snek/view/index.py\n@@ -1,6 +1,23 @@\n+\n+\n+\n+\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class IndexView(BaseView):\n-\tasync def get(A):\n-\t\tif A.session.get('uid'):return web.HTTPFound('/web.html')\n-\t\treturn await A.render_template('index.html')\n\\ No newline at end of file\n+    async def get(self):\n+        if self.session.get(\"uid\"):\n+            return web.HTTPFound(\"/web.html\")\n+\n+        return await self.render_template(\"index.html\")\ndiff --git a/src/snek/view/login.py b/src/snek/view/login.py\nindex 849a8e1..fe8cf4d 100644\n--- a/src/snek/view/login.py\n+++ b/src/snek/view/login.py\n@@ -1,15 +1,44 @@\n-_B='/web.html'\n-_A='logged_in'\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n+\n+\n class LoginView(BaseFormView):\n-\tform=LoginForm;login_required=False\n-\tasync def get(A):\n-\t\tif A.session.get(_A):return web.HTTPFound(_B)\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\treturn await A.render_template('login.html',{'form':await A.form(app=A.app).to_json()})\n-\tasync def submit(B,form):\n-\t\tD='color';E='uid';C='username'\n-\t\tif await form.is_valid:A=await B.services.user.get(username=form[C],deleted_at=None);await B.services.user.save(A);B.session.update({_A:True,C:A[C],E:A[E],D:A[D]});return{'redirect_url':_B}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = LoginForm\n+\n+    login_required = False\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\n+            \"login.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            user = await self.services.user.get(\n+                username=form[\"username\"], deleted_at=None\n+            )\n+            await self.services.user.save(user)\n+            self.session.update(\n+                {\n+                    \"logged_in\": True,\n+                    \"username\": user[\"username\"],\n+                    \"uid\": user[\"uid\"],\n+                    \"color\": user[\"color\"],\n+                }\n+            )\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/login_form.py b/src/snek/view/login_form.py\nindex 39b5be3..acf7c75 100644\n--- a/src/snek/view/login_form.py\n+++ b/src/snek/view/login_form.py\n@@ -1,8 +1,23 @@\n+\n+\n+\n+\n+\n from snek.form.login import LoginForm\n from snek.system.view import BaseFormView\n+\n+\n class LoginFormView(BaseFormView):\n-\tform=LoginForm\n-\tasync def submit(A,form):\n-\t\tB=form\n-\t\tif await B.is_valid():A.session['logged_in']=True;A.session['username']=B.username.value;A.session['uid']=B.uid.value;return{'redirect_url':'/web.html'}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = LoginForm\n+\n+    async def submit(self, form):\n+        if await form.is_valid():\n+            self.session[\"logged_in\"] = True\n+            self.session[\"username\"] = form.username.value\n+            self.session[\"uid\"] = form.uid.value\n+            return {\"redirect_url\": \"/web.html\"}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/logout.py b/src/snek/view/logout.py\nindex 5594774..42016d8 100644\n--- a/src/snek/view/logout.py\n+++ b/src/snek/view/logout.py\n@@ -1,14 +1,56 @@\n-_B='username'\n-_A='logged_in'\n+\n+\n+\n+\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class LogoutView(BaseView):\n-\tredirect_url='/';login_required=True\n-\tasync def get(A):\n-\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n-\t\texcept KeyError:pass\n-\t\treturn web.HTTPFound(A.redirect_url)\n-\tasync def post(A):\n-\t\ttry:del A.session[_A];del A.session['uid'];del A.session[_B]\n-\t\texcept KeyError:pass\n-\t\treturn await A.json_response({'redirect_url':A.redirect_url})\n\\ No newline at end of file\n+    redirect_url = \"/\"\n+    login_required = True\n+\n+    async def get(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return web.HTTPFound(self.redirect_url)\n+\n+    async def post(self):\n+        try:\n+            del self.session[\"logged_in\"]\n+            del self.session[\"uid\"]\n+            del self.session[\"username\"]\n+        except KeyError:\n+            pass\n+        return await self.json_response({\"redirect_url\": self.redirect_url})\ndiff --git a/src/snek/view/register.py b/src/snek/view/register.py\nindex ba48820..96eed8a 100644\n--- a/src/snek/view/register.py\n+++ b/src/snek/view/register.py\n@@ -1,12 +1,41 @@\n-_B='/web.html'\n-_A='logged_in'\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n+\n+\n class RegisterView(BaseFormView):\n-\tform=RegisterForm;login_required=False\n-\tasync def get(A):\n-\t\tif A.session.get(_A):return web.HTTPFound(_B)\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\treturn await A.render_template('register.html',{'form':await A.form(app=A.app).to_json()})\n-\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],_A:True,D:B[D]});return{'redirect_url':_B}\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    login_required = False\n+\n+    async def get(self):\n+        if self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/web.html\")\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        return await self.render_template(\n+            \"register.html\", {\"form\": await self.form(app=self.app).to_json()}\n+        )\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/register_form.py b/src/snek/view/register_form.py\nindex cf5dbbb..7b98647 100644\n--- a/src/snek/view/register_form.py\n+++ b/src/snek/view/register_form.py\n@@ -1,5 +1,47 @@\n+\n+\n+\n+\n from snek.form.register import RegisterForm\n from snek.system.view import BaseFormView\n+\n+\n class RegisterFormView(BaseFormView):\n-\tform=RegisterForm\n-\tasync def submit(C,form):D='color';E='username';F='uid';A=form;B=await C.app.services.user.register(A.email.value,A.username.value,A.password.value);C.request.session.update({F:B[F],E:B[E],'logged_in':True,D:B[D]});return{'redirect_url':'/web.html'}\n\\ No newline at end of file\n+    form = RegisterForm\n+\n+    async def submit(self, form):\n+        result = await self.app.services.user.register(\n+            form.email.value, form.username.value, form.password.value\n+        )\n+        self.request.session.update(\n+            {\n+                \"uid\": result[\"uid\"],\n+                \"username\": result[\"username\"],\n+                \"logged_in\": True,\n+                \"color\": result[\"color\"],\n+            }\n+        )\n+        return {\"redirect_url\": \"/web.html\"}\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 1896ba8..3161f49 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -1,105 +1,283 @@\n-_M='noresponse'\n-_L='deleted_at'\n-_K='Not allowed'\n-_J='password'\n-_I='logged_in'\n-_H='channel_uid'\n-_G='last_ping'\n-_F='nick'\n-_E=None\n-_D=True\n-_C=False\n-_B='username'\n-_A='uid'\n-import json,traceback\n+\n+\n+\n+\n+\n+import json\n+import traceback\n+\n from aiohttp import web\n+\n from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n+\n+\n class RPCView(BaseView):\n-\tclass RPCApi:\n-\t\tdef __init__(A,view,ws):A.view=view;A.app=A.view.app;A.services=A.app.services;A.ws=ws\n-\t\t@property\n-\t\tdef user_uid(self):return self.view.session.get(_A)\n-\t\t@property\n-\t\tdef request(self):return self.view.request\n-\t\tdef _require_login(A):\n-\t\t\tif not A.is_logged_in:raise Exception('Not logged in')\n-\t\t@property\n-\t\tdef is_logged_in(self):return self.view.session.get(_I,_C)\n-\t\tasync def mark_as_read(A,channel_uid):A._require_login();await A.services.channel_member.mark_as_read(channel_uid,A.user_uid);return _D\n-\t\tasync def login(A,username,password):\n-\t\t\tD=username;E=await A.services.user.validate_login(D,password)\n-\t\t\tif not E:raise Exception('Invalid username or password')\n-\t\t\tB=await A.services.user.get(username=D);A.view.session[_A]=B[_A];A.view.session[_I]=_D;A.view.session[_B]=B[_B];A.view.session['user_nick']=B[_F];C=B.record;del C[_J];del C[_L];await A.services.socket.add(A.ws,A.view.request.session.get(_A))\n-\t\t\tasync for F in A.services.channel_member.find(user_uid=A.view.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(A.ws,F[_H],A.view.request.session.get(_A))\n-\t\t\treturn C\n-\t\tasync def search_user(A,query):A._require_login();return[A[_B]for A in await A.services.user.search(query)]\n-\t\tasync def get_user(C,user_uid):\n-\t\t\tA=user_uid;C._require_login()\n-\t\t\tif not A:A=C.user_uid\n-\t\t\tD=await C.services.user.get(uid=A);B=D.record;del B[_J];del B[_L]\n-\t\t\tif A!=D[_A]:del B['email']\n-\t\t\treturn B\n-\t\tasync def get_messages(A,channel_uid,offset=0,timestamp=_E):\n-\t\t\tA._require_login();B=[]\n-\t\t\tfor C in await A.services.channel_message.offset(channel_uid,offset or 0,timestamp or _E):D=await A.services.channel_message.to_extended_dict(C);B.append(D)\n-\t\t\treturn B\n-\t\tasync def get_channels(B):\n-\t\t\tD='is_read_only';E='is_moderator';F='tag';G='color';C='new_count';B._require_login();H=[]\n-\t\t\tasync for A in B.services.channel_member.find(user_uid=B.user_uid,is_banned=_C):\n-\t\t\t\tI=await B.services.channel.get(uid=A[_H]);J=await I.get_last_message();K=_E\n-\t\t\t\tif J:L=await J.get_user();K=L[G]\n-\t\t\t\tH.append({'name':A['label'],_A:A[_H],F:I[F],C:A[C],E:A[E],D:A[D],C:A[C],G:K})\n-\t\t\treturn H\n-\t\tasync def send_message(A,channel_uid,message):A._require_login();await A.services.chat.send(A.user_uid,channel_uid,message);return _D\n-\t\tasync def echo(A,*B):A._require_login();return B\n-\t\tasync def query(B,*C):\n-\t\t\tB._require_login();E=C[0];D=E.lower()\n-\t\t\tif any(A in D for A in['drop','alter','update','delete','replace','insert','truncate'])and'select'not in D:raise Exception(_K)\n-\t\t\tF=[dict(A)async for A in B.services.channel.query(C[0])]\n-\t\t\tfor A in F:\n-\t\t\t\ttry:del A['email']\n-\t\t\t\texcept KeyError:pass\n-\t\t\t\ttry:del A[_J]\n-\t\t\t\texcept KeyError:pass\n-\t\t\t\ttry:del A['message']\n-\t\t\t\texcept:pass\n-\t\t\t\ttry:del A['html']\n-\t\t\t\texcept:pass\n-\t\t\treturn[dict(A)async for A in B.services.channel.query(C[0])]\n-\t\tasync def __call__(A,data):\n-\t\t\tI='success';E='data';F=data;B='callId'\n-\t\t\ttry:\n-\t\t\t\tG=F.get(B);C=F.get('method')\n-\t\t\t\tif C.startswith('_'):raise Exception(_K)\n-\t\t\t\tL=F.get('args')or[]\n-\t\t\t\tif hasattr(super(),C)or not hasattr(A,C):return await A._send_json({B:G,E:_K})\n-\t\t\t\tJ=getattr(A,C.replace('.','_'),_E)\n-\t\t\t\tif not J:raise Exception('Method not found')\n-\t\t\t\tK=_D\n-\t\t\t\ttry:H=await J(*L)\n-\t\t\t\texcept Exception as D:H={'exception':str(D),'traceback':traceback.format_exc()};K=_C\n-\t\t\t\tif H!=_M:await A._send_json({B:G,I:K,E:H})\n-\t\t\texcept Exception as D:print(str(D),flush=_D);await A._send_json({B:G,I:_C,E:str(D)})\n-\t\tasync def _send_json(A,obj):await A.ws.send_str(json.dumps(obj,default=str))\n-\t\tasync def get_online_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_online_users(channel_uid)]\n-\t\tasync def echo(A,obj):await A.ws.send_json(obj);return _M\n-\t\tasync def get_users(A,channel_uid):A._require_login();return[{_A:A[_A],_B:A[_B],_F:A[_F],_G:A[_G]}async for A in A.services.channel.get_users(channel_uid)]\n-\t\tasync def ping(A,callId,*C):\n-\t\t\tif A.user_uid:B=await A.services.user.get(uid=A.user_uid);B[_G]=now();await A.services.user.save(B)\n-\t\t\treturn{'pong':C}\n-\tasync def get(A):\n-\t\tB=web.WebSocketResponse();await B.prepare(A.request)\n-\t\tif A.request.session.get(_I):\n-\t\t\tawait A.services.socket.add(B,A.request.session.get(_A))\n-\t\t\tasync for D in A.services.channel_member.find(user_uid=A.request.session.get(_A),deleted_at=_E,is_banned=_C):await A.services.socket.subscribe(B,D[_H],A.request.session.get(_A))\n-\t\tE=RPCView.RPCApi(A,B)\n-\t\tasync for C in B:\n-\t\t\tif C.type==web.WSMsgType.TEXT:\n-\t\t\t\ttry:\n-\t\t\t\t\tasync with Profiler():await E(C.json())\n-\t\t\t\texcept Exception as F:print('Deleting socket',F,flush=_D);await A.services.socket.delete(B);break\n-\t\t\telif C.type==web.WSMsgType.ERROR:0\n-\t\t\telif C.type==web.WSMsgType.CLOSE:0\n-\t\treturn B\n\\ No newline at end of file\n+\n+    class RPCApi:\n+        def __init__(self, view, ws):\n+            self.view = view\n+            self.app = self.view.app\n+            self.services = self.app.services\n+            self.ws = ws\n+\n+        @property\n+        def user_uid(self):\n+            return self.view.session.get(\"uid\")\n+\n+        @property\n+        def request(self):\n+            return self.view.request\n+\n+        def _require_login(self):\n+            if not self.is_logged_in:\n+                raise Exception(\"Not logged in\")\n+\n+        @property\n+        def is_logged_in(self):\n+            return self.view.session.get(\"logged_in\", False)\n+\n+        async def mark_as_read(self, channel_uid):\n+            self._require_login()\n+            await self.services.channel_member.mark_as_read(channel_uid, self.user_uid)\n+            return True\n+\n+        async def login(self, username, password):\n+            success = await self.services.user.validate_login(username, password)\n+            if not success:\n+                raise Exception(\"Invalid username or password\")\n+            user = await self.services.user.get(username=username)\n+            self.view.session[\"uid\"] = user[\"uid\"]\n+            self.view.session[\"logged_in\"] = True\n+            self.view.session[\"username\"] = user[\"username\"]\n+            self.view.session[\"user_nick\"] = user[\"nick\"]\n+            record = user.record\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            await self.services.socket.add(\n+                self.ws, self.view.request.session.get(\"uid\")\n+            )\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.view.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    self.ws,\n+                    subscription[\"channel_uid\"],\n+                    self.view.request.session.get(\"uid\"),\n+                )\n+            return record\n+\n+        async def search_user(self, query):\n+            self._require_login()\n+            return [user[\"username\"] for user in await self.services.user.search(query)]\n+\n+        async def get_user(self, user_uid):\n+            self._require_login()\n+            if not user_uid:\n+                user_uid = self.user_uid\n+            user = await self.services.user.get(uid=user_uid)\n+            record = user.record\n+            del record[\"password\"]\n+            del record[\"deleted_at\"]\n+            if user_uid != user[\"uid\"]:\n+                del record[\"email\"]\n+            return record\n+\n+        async def get_messages(self, channel_uid, offset=0, timestamp=None):\n+            self._require_login()\n+            messages = []\n+            for message in await self.services.channel_message.offset(\n+                channel_uid, offset or 0, timestamp or None\n+            ):\n+                extended_dict = await self.services.channel_message.to_extended_dict(\n+                    message\n+                )\n+                messages.append(extended_dict)\n+            return messages\n+\n+        async def get_channels(self):\n+            self._require_login()\n+            channels = []\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.user_uid, is_banned=False\n+            ):\n+                channel = await self.services.channel.get(\n+                    uid=subscription[\"channel_uid\"]\n+                )\n+                last_message = await channel.get_last_message()\n+                color = None\n+                if last_message:\n+                    last_message_user = await last_message.get_user()\n+                    color = last_message_user[\"color\"]\n+                channels.append(\n+                    {\n+                        \"name\": subscription[\"label\"],\n+                        \"uid\": subscription[\"channel_uid\"],\n+                        \"tag\": channel[\"tag\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"is_moderator\": subscription[\"is_moderator\"],\n+                        \"is_read_only\": subscription[\"is_read_only\"],\n+                        \"new_count\": subscription[\"new_count\"],\n+                        \"color\": color,\n+                    }\n+                )\n+            return channels\n+\n+        async def send_message(self, channel_uid, message):\n+            self._require_login()\n+            await self.services.chat.send(self.user_uid, channel_uid, message)\n+            return True\n+\n+        async def echo(self, *args):\n+            self._require_login()\n+            return args\n+\n+        async def query(self, *args):\n+            self._require_login()\n+            query = args[0]\n+            lowercase = query.lower()\n+            if (\n+                any(\n+                    keyword in lowercase\n+                    for keyword in [\n+                        \"drop\",\n+                        \"alter\",\n+                        \"update\",\n+                        \"delete\",\n+                        \"replace\",\n+                        \"insert\",\n+                        \"truncate\",\n+                    ]\n+                )\n+                and \"select\" not in lowercase\n+            ):\n+                raise Exception(\"Not allowed\")\n+            records = [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n+            for record in records:\n+                try:\n+                    del record[\"email\"]\n+                except KeyError:\n+                    pass\n+                try:\n+                    del record[\"password\"]\n+                except KeyError:\n+                    pass\n+                try:\n+                    del record[\"message\"]\n+                except:\n+                    pass\n+                try:\n+                    del record[\"html\"]\n+                except:\n+                    pass\n+            return [\n+                dict(record) async for record in self.services.channel.query(args[0])\n+            ]\n+\n+        async def __call__(self, data):\n+            try:\n+                call_id = data.get(\"callId\")\n+                method_name = data.get(\"method\")\n+                if method_name.startswith(\"_\"):\n+                    raise Exception(\"Not allowed\")\n+                args = data.get(\"args\") or []\n+                if hasattr(super(), method_name) or not hasattr(self, method_name):\n+                    return await self._send_json(\n+                        {\"callId\": call_id, \"data\": \"Not allowed\"}\n+                    )\n+                method = getattr(self, method_name.replace(\".\", \"_\"), None)\n+                if not method:\n+                    raise Exception(\"Method not found\")\n+                success = True\n+                try:\n+                    result = await method(*args)\n+                except Exception as ex:\n+                    result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n+                    success = False\n+                if result != \"noresponse\":\n+                    await self._send_json(\n+                        {\"callId\": call_id, \"success\": success, \"data\": result}\n+                    )\n+            except Exception as ex:\n+                print(str(ex), flush=True)\n+                await self._send_json(\n+                    {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n+                )\n+\n+        async def _send_json(self, obj):\n+            await self.ws.send_str(json.dumps(obj, default=str))\n+\n+        async def get_online_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_online_users(channel_uid)\n+            ]\n+\n+        async def echo(self, obj):\n+            await self.ws.send_json(obj)\n+            return \"noresponse\"\n+\n+        async def get_users(self, channel_uid):\n+            self._require_login()\n+\n+            return [\n+                {\n+                    \"uid\": record[\"uid\"],\n+                    \"username\": record[\"username\"],\n+                    \"nick\": record[\"nick\"],\n+                    \"last_ping\": record[\"last_ping\"],\n+                }\n+                async for record in self.services.channel.get_users(channel_uid)\n+            ]\n+\n+        async def ping(self, callId, *args):\n+            if self.user_uid:\n+                user = await self.services.user.get(uid=self.user_uid)\n+                user[\"last_ping\"] = now()\n+                await self.services.user.save(user)\n+            return {\"pong\": args}\n+\n+    async def get(self):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        if self.request.session.get(\"logged_in\"):\n+            await self.services.socket.add(ws, self.request.session.get(\"uid\"))\n+            async for subscription in self.services.channel_member.find(\n+                user_uid=self.request.session.get(\"uid\"),\n+                deleted_at=None,\n+                is_banned=False,\n+            ):\n+                await self.services.socket.subscribe(\n+                    ws, subscription[\"channel_uid\"], self.request.session.get(\"uid\")\n+                )\n+        rpc = RPCView.RPCApi(self, ws)\n+        async for msg in ws:\n+            if msg.type == web.WSMsgType.TEXT:\n+                try:\n+                    async with Profiler():\n+                        await rpc(msg.json())\n+                except Exception as ex:\n+                    print(\"Deleting socket\", ex, flush=True)\n+                    await self.services.socket.delete(ws)\n+                    break\n+            elif msg.type == web.WSMsgType.ERROR:\n+                pass\n+            elif msg.type == web.WSMsgType.CLOSE:\n+                pass\n+        return ws\ndiff --git a/src/snek/view/search_user.py b/src/snek/view/search_user.py\nindex 5e5b7e2..1f09a26 100644\n--- a/src/snek/view/search_user.py\n+++ b/src/snek/view/search_user.py\n@@ -1,12 +1,56 @@\n+\n+\n+\n+\n+\n from snek.form.search_user import SearchUserForm\n from snek.system.view import BaseFormView\n+\n+\n class SearchUserView(BaseFormView):\n-\tform=SearchUserForm;login_required=True\n-\tasync def get(A):\n-\t\tC='query';D=[];B=A.request.query.get(C)\n-\t\tif B:D=[A.record for A in await A.app.services.user.search(B)]\n-\t\tif A.request.path.endswith('.json'):return await super().get()\n-\t\tE=await A.app.services.user.get(uid=A.session.get('uid'));return await A.render_template('search_user.html',{'users':D,C:B or'','current_user':E})\n-\tasync def submit(A,form):\n-\t\tif await form.is_valid:return{'redirect_url':'/search-user.html?query='+form['username']}\n-\t\treturn{'is_valid':False}\n\\ No newline at end of file\n+    form = SearchUserForm\n+    login_required = True\n+\n+    async def get(self):\n+        users = []\n+        query = self.request.query.get(\"query\")\n+        if query:\n+            users = [user.record for user in await self.app.services.user.search(query)]\n+\n+        if self.request.path.endswith(\".json\"):\n+            return await super().get()\n+        current_user = await self.app.services.user.get(uid=self.session.get(\"uid\"))\n+        return await self.render_template(\n+            \"search_user.html\",\n+            {\"users\": users, \"query\": query or \"\", \"current_user\": current_user},\n+        )\n+\n+    async def submit(self, form):\n+        if await form.is_valid:\n+            return {\"redirect_url\": \"/search-user.html?query=\" + form[\"username\"]}\n+        return {\"is_valid\": False}\ndiff --git a/src/snek/view/settings/index.py b/src/snek/view/settings/index.py\nindex fc58857..418ef3d 100644\n--- a/src/snek/view/settings/index.py\n+++ b/src/snek/view/settings/index.py\n@@ -1,4 +1,9 @@\n from snek.system.view import BaseView\n+\n+\n class SettingsIndexView(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):return await A.render_template('settings/index.html')\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+        return await self.render_template(\"settings/index.html\")\ndiff --git a/src/snek/view/settings/profile.py b/src/snek/view/settings/profile.py\nindex 4a3f897..164c526 100644\n--- a/src/snek/view/settings/profile.py\n+++ b/src/snek/view/settings/profile.py\n@@ -1,13 +1,38 @@\n-_C='profile'\n-_B='uid'\n-_A='nick'\n from aiohttp import web\n+\n from snek.form.settings.profile import SettingsProfileForm\n from snek.system.view import BaseFormView\n+\n+\n class SettingsProfileView(BaseFormView):\n-\tform=SettingsProfileForm;login_required=True\n-\tasync def get(A):\n-\t\tC='user';B=A.form(app=A.app)\n-\t\tif A.request.path.endswith('.json'):B[_A]=A.request[C][_A];return web.json_response(await B.to_json())\n-\t\tD=await A.services.user_property.get(A.session.get(_B),_C);E=await A.services.user.get(uid=A.session.get(_B));return await A.render_template('settings/profile.html',{'form':await B.to_json(),C:E,_C:D or''})\n-\tasync def post(A):C=await A.request.post();B=await A.services.user.get(uid=A.session.get(_B));B[_A]=C[_A];await A.services.user.save(B);await A.services.user_property.set(B[_B],_C,C[_C]);return web.HTTPFound('/settings/profile.html')\n\\ No newline at end of file\n+    form = SettingsProfileForm\n+\n+    login_required = True\n+\n+    async def get(self):\n+        form = self.form(app=self.app)\n+\n+        if self.request.path.endswith(\".json\"):\n+            form[\"nick\"] = self.request[\"user\"][\"nick\"]\n+\n+            return web.json_response(await form.to_json())\n+\n+        profile = await self.services.user_property.get(\n+            self.session.get(\"uid\"), \"profile\"\n+        )\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        return await self.render_template(\n+            \"settings/profile.html\",\n+            {\"form\": await form.to_json(), \"user\": user, \"profile\": profile or \"\"},\n+        )\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        user[\"nick\"] = data[\"nick\"]\n+        await self.services.user.save(user)\n+        await self.services.user_property.set(user[\"uid\"], \"profile\", data[\"profile\"])\n+        return web.HTTPFound(\"/settings/profile.html\")\ndiff --git a/src/snek/view/settings/repositories.py b/src/snek/view/settings/repositories.py\nindex 1cf96fd..093d229 100644\n--- a/src/snek/view/settings/repositories.py\n+++ b/src/snek/view/settings/repositories.py\n@@ -1,37 +1,86 @@\n-_F='repository'\n-_E='/settings/repositories/index.html'\n-_D='is_private'\n-_C=True\n-_B='name'\n-_A='uid'\n import asyncio\n from aiohttp import web\n+\n from snek.system.view import BaseFormView\n import pathlib\n+\n class RepositoriesIndexView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tC=A.session.get(_A);B=[]\n-\t\tasync for D in A.services.repository.find(user_uid=C):B.append(D.record)\n-\t\tE=await A.services.user.get(uid=A.session.get(_A));return await A.render_template('settings/repositories/index.html',{'repositories':B,'user':E})\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        user_uid = self.session.get(\"uid\")\n+        \n+        repositories = []\n+        async for repository in self.services.repository.find(user_uid=user_uid):\n+            repositories.append(repository.record)\n+        \n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+\n+        return await self.render_template(\"settings/repositories/index.html\", {\"repositories\": repositories, \"user\": user})\n+\n+\n+\n+\n class RepositoriesCreateView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):return await A.render_template('settings/repositories/create.html')\n-\tasync def post(A):B=await A.request.post();C=await A.services.repository.create(user_uid=A.session.get(_A),name=B[_B],is_private=int(B.get(_D,0)));return web.HTTPFound(_E)\n+\n+    login_required = True\n+\n+    async def get(self):\n+        \n+        return await self.render_template(\"settings/repositories/create.html\")\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.create(user_uid=self.session.get(\"uid\"), name=data['name'], is_private=int(data.get('is_private',0)))\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n class RepositoriesUpdateView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n-\t\tif not B:return web.HTTPNotFound()\n-\t\treturn await A.render_template('settings/repositories/update.html',{_F:B.record})\n-\tasync def post(A):C=await A.request.post();B=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B]);B[_D]=int(C.get(_D,0));await A.services.repository.save(B);return web.HTTPFound(_E)\n+\n+    login_required = True\n+\n+    async def get(self):\n+\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        return await self.render_template(\"settings/repositories/update.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        data = await self.request.post()\n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        repository['is_private'] = int(data.get('is_private',0))\n+        await self.services.repository.save(repository)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n class RepositoriesDeleteView(BaseFormView):\n-\tlogin_required=_C\n-\tasync def get(A):\n-\t\tB=await A.services.repository.get(user_uid=A.session.get(_A),name=A.request.match_info[_B])\n-\t\tif not B:return web.HTTPNotFound()\n-\t\treturn await A.render_template('settings/repositories/delete.html',{_F:B.record})\n-\tasync def post(A):\n-\t\tB=A.session.get(_A);C=A.request.match_info[_B];D=await A.services.repository.get(user_uid=B,name=C)\n-\t\tif not D:return web.HTTPNotFound()\n-\t\tawait A.services.repository.delete(user_uid=B,name=C);return web.HTTPFound(_E)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+            \n+        repository = await self.services.repository.get(\n+            user_uid=self.session.get(\"uid\"), name=self.request.match_info[\"name\"]\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+\n+        return await self.render_template(\"settings/repositories/delete.html\", {\"repository\": repository.record})\n+\n+    async def post(self):\n+        user_uid = self.session.get(\"uid\")\n+        name = self.request.match_info[\"name\"]\n+        repository = await self.services.repository.get(\n+                user_uid=user_uid, name=name\n+        )\n+        if not repository:\n+            return web.HTTPNotFound()\n+        await self.services.repository.delete(user_uid=user_uid, name=name)\n+        return web.HTTPFound(\"/settings/repositories/index.html\")\n+\n+\ndiff --git a/src/snek/view/stats.py b/src/snek/view/stats.py\nindex dbf7fc6..1680c5c 100644\n--- a/src/snek/view/stats.py\n+++ b/src/snek/view/stats.py\n@@ -1,5 +1,13 @@\n import json\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class StatsView(BaseView):\n-\tasync def get(B):A=await B.app.cache.get_stats();A=json.dumps({'total':len(A),'stats':A},default=str,indent=1);return web.Response(text=A,content_type='application/json')\n\\ No newline at end of file\n+\n+    async def get(self):\n+        data = await self.app.cache.get_stats()\n+        data = json.dumps({\"total\": len(data), \"stats\": data}, default=str, indent=1)\n+        return web.Response(text=data, content_type=\"application/json\")\ndiff --git a/src/snek/view/status.py b/src/snek/view/status.py\nindex 672d20f..4675572 100644\n--- a/src/snek/view/status.py\n+++ b/src/snek/view/status.py\n@@ -1,10 +1,73 @@\n+\n+\n+\n+\n from snek.system.view import BaseView\n+\n+\n class StatusView(BaseView):\n-\tasync def get(C):\n-\t\tG='color';H='nick';I='email';J='username';K='is_banned';L='is_muted';M='is_read_only';N='is_moderator';O='user_uid';P='description';E='channel_uid';D='uid';Q=[];A={};F=C.session.get(D)\n-\t\tif F:\n-\t\t\tA=await C.app.services.user.get(uid=F)\n-\t\t\tif not A:return await C.json_response({'error':'User not found'},status=404)\n-\t\t\tasync for B in C.app.services.channel_member.find(user_uid=F,deleted_at=None,is_banned=False):R=await C.app.services.channel.get(uid=B[E]);Q.append({'name':R['label'],P:B[P],O:B[O],N:B[N],M:B[M],L:B[L],K:B[K],E:B[E],D:B[D]})\n-\t\t\tA={J:A[J],I:A[I],H:A[H],D:A[D],G:A[G],'memberships':Q}\n-\t\treturn await C.json_response({'user':A,'cache':await C.app.cache.create_cache_key(C.app.cache.cache,None)})\n\\ No newline at end of file\n+    async def get(self):\n+        memberships = []\n+        user = {}\n+\n+        user_id = self.session.get(\"uid\")\n+        if user_id:\n+            user = await self.app.services.user.get(uid=user_id)\n+            if not user:\n+                return await self.json_response({\"error\": \"User not found\"}, status=404)\n+\n+            async for model in self.app.services.channel_member.find(\n+                user_uid=user_id, deleted_at=None, is_banned=False\n+            ):\n+                channel = await self.app.services.channel.get(uid=model[\"channel_uid\"])\n+                memberships.append(\n+                    {\n+                        \"name\": channel[\"label\"],\n+                        \"description\": model[\"description\"],\n+                        \"user_uid\": model[\"user_uid\"],\n+                        \"is_moderator\": model[\"is_moderator\"],\n+                        \"is_read_only\": model[\"is_read_only\"],\n+                        \"is_muted\": model[\"is_muted\"],\n+                        \"is_banned\": model[\"is_banned\"],\n+                        \"channel_uid\": model[\"channel_uid\"],\n+                        \"uid\": model[\"uid\"],\n+                    }\n+                )\n+            user = {\n+                \"username\": user[\"username\"],\n+                \"email\": user[\"email\"],\n+                \"nick\": user[\"nick\"],\n+                \"uid\": user[\"uid\"],\n+                \"color\": user[\"color\"],\n+                \"memberships\": memberships,\n+            }\n+\n+        return await self.json_response(\n+            {\n+                \"user\": user,\n+                \"cache\": await self.app.cache.create_cache_key(\n+                    self.app.cache.cache, None\n+                ),\n+            }\n+        )\ndiff --git a/src/snek/view/terminal.py b/src/snek/view/terminal.py\nindex 43c1fd1..d3af9b0 100644\n--- a/src/snek/view/terminal.py\n+++ b/src/snek/view/terminal.py\n@@ -1,23 +1,54 @@\n-_B=True\n-_A='uid'\n-import pathlib,aiohttp\n+import pathlib\n+\n+import aiohttp\n+\n from snek.system.terminal import TerminalSession\n from snek.system.view import BaseView\n+\n+\n class TerminalSocketView(BaseView):\n-\tlogin_required=_B;user_sessions={}\n-\tasync def prepare_drive(C):\n-\t\tD=await C.services.user.get(uid=C.session.get(_A));A=pathlib.Path('drive').joinpath(D[_A]);A.mkdir(parents=_B,exist_ok=_B);E=pathlib.Path('terminal')\n-\t\tfor B in E.iterdir():\n-\t\t\tF=A.joinpath(B.name)\n-\t\t\tif not B.is_dir():F.write_bytes(B.read_bytes())\n-\t\treturn A\n-\tasync def get(A):\n-\t\tB=aiohttp.web.WebSocketResponse();await B.prepare(A.request);D=await A.services.user.get(uid=A.session.get(_A));F=await A.prepare_drive();G=f\"docker run -v ./{F}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\";C=A.user_sessions.get(D[_A])\n-\t\tif not C:A.user_sessions[D[_A]]=TerminalSession(command=G)\n-\t\tC=A.user_sessions[D[_A]];await C.add_websocket(B)\n-\t\tasync for E in B:\n-\t\t\tif E.type==aiohttp.WSMsgType.BINARY:await C.write_input(E.data.decode())\n-\t\treturn B\n+\n+    login_required = True\n+\n+    user_sessions = {}\n+\n+    async def prepare_drive(self):\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = pathlib.Path(\"drive\").joinpath(user[\"uid\"])\n+        root.mkdir(parents=True, exist_ok=True)\n+        terminal_folder = pathlib.Path(\"terminal\")\n+        for path in terminal_folder.iterdir():\n+            destination_path = root.joinpath(path.name)\n+            if not path.is_dir():\n+                destination_path.write_bytes(path.read_bytes())\n+        return root\n+\n+    async def get(self):\n+\n+        ws = aiohttp.web.WebSocketResponse()\n+        await ws.prepare(self.request)\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        root = await self.prepare_drive()\n+\n+        command = f\"docker run -v ./{root}/:/root -it --memory 512M --cpus=0.5 -w /root snek_ubuntu /bin/bash\"\n+\n+        session = self.user_sessions.get(user[\"uid\"])\n+        if not session:\n+            self.user_sessions[user[\"uid\"]] = TerminalSession(command=command)\n+        session = self.user_sessions[user[\"uid\"]]\n+        await session.add_websocket(ws)\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.BINARY:\n+                await session.write_input(msg.data.decode())\n+\n+        return ws\n+\n+\n class TerminalView(BaseView):\n-\tlogin_required=_B\n-\tasync def get(A):return await A.request.app.render_template('terminal.html',A.request)\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    async def get(self):\n+        return await self.request.app.render_template(\"terminal.html\", self.request)\ndiff --git a/src/snek/view/threads.py b/src/snek/view/threads.py\nindex 3b7425d..bc923c6 100644\n--- a/src/snek/view/threads.py\n+++ b/src/snek/view/threads.py\n@@ -1,11 +1,37 @@\n from snek.system.view import BaseView\n+\n+\n class ThreadsView(BaseView):\n-\tasync def get(B):\n-\t\tI='color';J='user_uid';K='name_color';L='new_count';F='uid';C='last_message_on';G=[];M=await B.services.user.get(uid=B.session.get(F))\n-\t\tasync for H in M.get_channel_members():\n-\t\t\tA={};D=await B.services.channel.get(uid=H['channel_uid']);E=await D.get_last_message()\n-\t\t\tif not E:continue\n-\t\t\tif D['tag']=='dm':A[K]=N[I]\n-\t\t\tA['last_message_user_color']=N[I];G.append(A)\n-\t\tG.sort(key=lambda x:x[C]or'',reverse=True);return await B.render_template('threads.html',{'threads':G,'user':M})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        threads = []\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        async for channel_member in user.get_channel_members():\n+            thread = {}\n+            channel = await self.services.channel.get(uid=channel_member[\"channel_uid\"])\n+            last_message = await channel.get_last_message()\n+            if not last_message:\n+                continue\n+\n+            thread[\"uid\"] = channel[\"uid\"]\n+            thread[\"name\"] = await channel_member.get_name()\n+            thread[\"new_count\"] = channel_member[\"new_count\"]\n+            thread[\"last_message_on\"] = channel[\"last_message_on\"]\n+            thread[\"created_at\"] = thread[\"last_message_on\"]\n+\n+            thread[\"last_message_text\"] = last_message[\"message\"]\n+            thread[\"last_message_user_uid\"] = last_message[\"user_uid\"]\n+            user_last_message = await self.app.services.user.get(\n+                uid=last_message[\"user_uid\"]\n+            )\n+            if channel[\"tag\"] == \"dm\":\n+                thread[\"name_color\"] = user_last_message[\"color\"]\n+            thread[\"last_message_user_color\"] = user_last_message[\"color\"]\n+            threads.append(thread)\n+\n+        threads.sort(key=lambda x: x[\"last_message_on\"] or \"\", reverse=True)\n+\n+        return await self.render_template(\n+            \"threads.html\", {\"threads\": threads, \"user\": user}\n+        )\ndiff --git a/src/snek/view/upload.py b/src/snek/view/upload.py\nindex e45d5f6..cf01948 100644\n--- a/src/snek/view/upload.py\n+++ b/src/snek/view/upload.py\n@@ -1,19 +1,111 @@\n-_A='uid'\n-import pathlib,uuid,aiofiles\n+\n+\n+\n+\n+import pathlib\n+import uuid\n+\n+import aiofiles\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class UploadView(BaseView):\n-\tasync def get(B):D=B.request.match_info.get(_A);C=await B.services.drive_item.get(D);A=web.FileResponse(C['path']);A.headers['Cache-Control']=f\"public, max-age={561540}\";A.headers['Content-Disposition']=f'attachment; filename=\"{C[\"name\"]}\"';return A\n-\tasync def post(A):\n-\t\tK='](/drive.bin/';L='channel_uid';G='document';D='image';P=await A.request.multipart();M=[];Q=A.request.session.get(_A);E=await A.services.user.get_home_folder(Q);E=E.joinpath('upload');E.mkdir(parents=True,exist_ok=True);H=None;R=await A.services.drive.get_or_create(user_uid=A.request.session.get(_A));N={'.jpg':D,'.gif':D,'.png':D,'.jpeg':D,'.mp4':'video','.mp3':'audio','.pdf':G,'.doc':G,'.docx':G}\n-\t\twhile(F:=await P.next()):\n-\t\t\tif F.name==L:H=await F.text();continue\n-\t\t\tB=F.filename\n-\t\t\tif not B:continue\n-\t\t\tS=str(uuid.uuid4())+pathlib.Path(B).suffix;C=E.joinpath(S);M.append(C)\n-\t\t\tasync with aiofiles.open(str(C),'wb')as T:\n-\t\t\t\twhile(U:=await F.read_chunk()):await T.write(U)\n-\t\t\tI=await A.services.drive_item.create(R[_A],B,str(C),C.stat().st_size,C.suffix);J='.'+B.split('.')[-1]\n-\t\t\tif J in N:N[J]\n-\t\t\tawait A.services.drive_item.save(I);O='Uploaded ['+B+K+I[_A]+')';O='['+B+K+I[_A]+J+')';await A.services.chat.send(A.request.session.get(_A),H,O)\n-\t\treturn web.json_response({'message':'Files uploaded successfully','files':[str(A)for A in M],L:H})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        uid = self.request.match_info.get(\"uid\")\n+        drive_item = await self.services.drive_item.get(uid)\n+        response = web.FileResponse(drive_item[\"path\"])\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{drive_item[\"name\"]}\"'\n+        )\n+        return response\n+\n+    async def post(self):\n+        reader = await self.request.multipart()\n+        files = []\n+\n+        user_uid = self.request.session.get(\"uid\")\n+\n+        upload_dir = await self.services.user.get_home_folder(user_uid)\n+        upload_dir = upload_dir.joinpath(\"upload\")\n+        upload_dir.mkdir(parents=True, exist_ok=True)\n+\n+        channel_uid = None\n+\n+        drive = await self.services.drive.get_or_create(\n+            user_uid=self.request.session.get(\"uid\")\n+        )\n+\n+        extension_types = {\n+            \".jpg\": \"image\",\n+            \".gif\": \"image\",\n+            \".png\": \"image\",\n+            \".jpeg\": \"image\",\n+            \".mp4\": \"video\",\n+            \".mp3\": \"audio\",\n+            \".pdf\": \"document\",\n+            \".doc\": \"document\",\n+            \".docx\": \"document\",\n+        }\n+\n+        while field := await reader.next():\n+            if field.name == \"channel_uid\":\n+                channel_uid = await field.text()\n+                continue\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+\n+            name = str(uuid.uuid4()) + pathlib.Path(filename).suffix\n+\n+            file_path = upload_dir.joinpath(name)\n+            files.append(file_path)\n+\n+            async with aiofiles.open(str(file_path), \"wb\") as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+            drive_item = await self.services.drive_item.create(\n+                drive[\"uid\"],\n+                filename,\n+                str(file_path),\n+                file_path.stat().st_size,\n+                file_path.suffix,\n+            )\n+\n+            extension = \".\" + filename.split(\".\")[-1]\n+            if extension in extension_types:\n+                extension_types[extension]\n+\n+            await self.services.drive_item.save(drive_item)\n+            response = (\n+                \"Uploaded [\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + \")\"\n+            )\n+            response = (\n+                \"[\" + filename + \"](/drive.bin/\" + drive_item[\"uid\"] + extension + \")\"\n+            )\n+\n+            await self.services.chat.send(\n+                self.request.session.get(\"uid\"), channel_uid, response\n+            )\n+\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [str(file) for file in files],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )\ndiff --git a/src/snek/view/user.py b/src/snek/view/user.py\nindex ab00e1a..312f7bf 100644\n--- a/src/snek/view/user.py\n+++ b/src/snek/view/user.py\n@@ -1,3 +1,15 @@\n from snek.system.view import BaseView\n+\n+\n class UserView(BaseView):\n-\tasync def get(A):B='profile';C='user';D=A.request.match_info.get(C);E=await A.services.user.get(uid=D);F=await A.services.user_property.get(E['uid'],B)or'';return await A.render_template('user.html',{'user_uid':D,C:E.record,B:F})\n\\ No newline at end of file\n+\n+    async def get(self):\n+        user_uid = self.request.match_info.get(\"user\")\n+        user = await self.services.user.get(uid=user_uid)\n+        profile_content = (\n+            await self.services.user_property.get(user[\"uid\"], \"profile\") or \"\"\n+        )\n+        return await self.render_template(\n+            \"user.html\",\n+            {\"user_uid\": user_uid, \"user\": user.record, \"profile\": profile_content},\n+        )\ndiff --git a/src/snek/view/web.py b/src/snek/view/web.py\nindex 292586d..111f76c 100644\n--- a/src/snek/view/web.py\n+++ b/src/snek/view/web.py\n@@ -1,19 +1,79 @@\n+\n+\n+\n+\n from aiohttp import web\n+\n from snek.system.view import BaseView\n+\n+\n class WebView(BaseView):\n-\tlogin_required=True\n-\tasync def get(A):\n-\t\tF='channel';B='uid'\n-\t\tif A.login_required and not A.session.get('logged_in'):return web.HTTPFound('/')\n-\t\tC=await A.services.channel.get(uid=A.request.match_info.get(F))\n-\t\tif not C:\n-\t\t\tD=await A.services.user.get(uid=A.request.match_info.get(F))\n-\t\t\tif D:\n-\t\t\t\tC=await A.services.channel.get_dm(A.session.get(B),D[B])\n-\t\t\t\tif C:return web.HTTPFound('/channel/{}.html'.format(C[B]))\n-\t\tif not C:return web.HTTPNotFound()\n-\t\tE=await A.app.services.channel_member.get(user_uid=A.session.get(B),channel_uid=C[B])\n-\t\tif not E:return web.HTTPNotFound()\n-\t\tE['new_count']=0;await A.app.services.channel_member.save(E);D=await A.services.user.get(uid=A.session.get(B));G=[await A.app.services.channel_message.to_extended_dict(B)for B in await A.app.services.channel_message.offset(C[B])]\n-\t\tfor H in G:await A.app.services.notification.mark_as_read(A.session.get(B),H[B])\n-\t\tI=await E.get_name();return await A.render_template('web.html',{'name':I,F:C,'user':D,'messages':G})\n\\ No newline at end of file\n+    login_required = True\n+\n+    async def get(self):\n+        if self.login_required and not self.session.get(\"logged_in\"):\n+            return web.HTTPFound(\"/\")\n+        channel = await self.services.channel.get(\n+            uid=self.request.match_info.get(\"channel\")\n+        )\n+        if not channel:\n+            user = await self.services.user.get(\n+                uid=self.request.match_info.get(\"channel\")\n+            )\n+            if user:\n+                channel = await self.services.channel.get_dm(\n+                    self.session.get(\"uid\"), user[\"uid\"]\n+                )\n+                if channel:\n+                    return web.HTTPFound(\"/channel/{}.html\".format(channel[\"uid\"]))\n+        if not channel:\n+            return web.HTTPNotFound()\n+\n+        channel_member = await self.app.services.channel_member.get(\n+            user_uid=self.session.get(\"uid\"), channel_uid=channel[\"uid\"]\n+        )\n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n+        channel_member[\"new_count\"] = 0\n+        await self.app.services.channel_member.save(channel_member)\n+\n+        user = await self.services.user.get(uid=self.session.get(\"uid\"))\n+        messages = [\n+            await self.app.services.channel_message.to_extended_dict(message)\n+            for message in await self.app.services.channel_message.offset(\n+                channel[\"uid\"]\n+            )\n+        ]\n+        for message in messages:\n+            await self.app.services.notification.mark_as_read(\n+                self.session.get(\"uid\"), message[\"uid\"]\n+            )\n+\n+        name = await channel_member.get_name()\n+        return await self.render_template(\n+            \"web.html\",\n+            {\"name\": name, \"channel\": channel, \"user\": user, \"messages\": messages},\n+        )\ndiff --git a/src/snek/webdav.py b/src/snek/webdav.py\nindex 0d0ae16..4c57fab 100755\n--- a/src/snek/webdav.py\n+++ b/src/snek/webdav.py\n@@ -1,145 +1,377 @@\n-_U='Lock-Token'\n-_T='application/xml'\n-_S='{DAV:}exclusive'\n-_R='{DAV:}lockdiscovery'\n-_Q='{DAV:}prop'\n-_P='%a, %d %b %Y %H:%M:%S GMT'\n-_O='Source not found'\n-_M='Destination'\n-_L='application/octet-stream'\n-_K='File not found'\n-_J='{DAV:}write'\n-_I='{DAV:}locktype'\n-_H='{DAV:}lockscope'\n-_G='{DAV:}href'\n-_F='Content-Type'\n-_E=True\n-_D='filename'\n-_C='Basic realm=\"WebDAV\"'\n-_B='WWW-Authenticate'\n-_A='home'\n-import logging,pathlib\n+import logging\n+import pathlib\n+\n logging.basicConfig(level=logging.DEBUG)\n-import base64,datetime,mimetypes,os,shutil,uuid,aiofiles,aiohttp,aiohttp.web\n+import base64\n+import datetime\n+import mimetypes\n+import os\n+import shutil\n+import uuid\n+\n+import aiofiles\n+import aiohttp\n+import aiohttp.web\n from app.cache import time_cache_async\n from lxml import etree\n+\n+\n @aiohttp.web.middleware\n-async def debug_middleware(request,handler):\n-\tA=request;print(A.method,A.path,A.headers);B=await handler(A);print(B.status)\n-\ttry:print(await B.text())\n-\texcept:pass\n-\treturn B\n+async def debug_middleware(request, handler):\n+    print(request.method, request.path, request.headers)\n+    result = await handler(request)\n+    print(result.status)\n+    try:\n+        print(await result.text())\n+    except:\n+        pass\n+    return result\n+\n+\n class WebdavApplication(aiohttp.web.Application):\n-\tdef __init__(A,parent,*C,**D):B='/{filename:.*}';E=[debug_middleware];super().__init__(*C,middlewares=E,**D);A.locks={};A.relative_url='/webdav';A.router.add_route('OPTIONS',B,A.handle_options);A.router.add_route('GET',B,A.handle_get);A.router.add_route('PUT',B,A.handle_put);A.router.add_route('DELETE',B,A.handle_delete);A.router.add_route('MKCOL',B,A.handle_mkcol);A.router.add_route('MOVE',B,A.handle_move);A.router.add_route('COPY',B,A.handle_copy);A.router.add_route('PROPFIND',B,A.handle_propfind);A.router.add_route('PROPPATCH',B,A.handle_proppatch);A.router.add_route('LOCK',B,A.handle_lock);A.router.add_route('UNLOCK',B,A.handle_unlock);A.parent=parent\n-\t@property\n-\tdef db(self):return self.parent.db\n-\t@property\n-\tdef services(self):return self.parent.services\n-\tasync def authenticate(C,request):\n-\t\tD='Basic ';B='user';A=request;E=A.headers.get('Authorization','')\n-\t\tif not E.startswith(D):return False\n-\t\tF=E.split(D)[1];G=base64.b64decode(F).decode();H,I=G.split(':',1);A[B]=await C.services.user.authenticate(username=H,password=I)\n-\t\ttry:A[_A]=await C.services.user.get_home_folder(A[B]['uid'])\n-\t\texcept Exception:pass\n-\t\treturn A[B]\n-\tasync def handle_get(D,request):\n-\t\tB=request\n-\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n-\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n-\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot download a directory')\n-\t\tC,F=mimetypes.guess_type(str(A));C=C or _L;return aiohttp.web.FileResponse(path=str(A),headers={_F:C},chunk_size=8192)\n-\tasync def handle_put(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];B.parent.mkdir(parents=_E,exist_ok=_E)\n-\t\tasync with aiofiles.open(B,'wb')as D:\n-\t\t\twhile(E:=await A.content.read(1024)):await D.write(E)\n-\t\treturn aiohttp.web.Response(status=201,text='File uploaded')\n-\tasync def handle_delete(C,request):\n-\t\tB=request\n-\t\tif not await C.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tA=B[_A]/B.match_info[_D]\n-\t\tif A.is_file():A.unlink();return aiohttp.web.Response(status=204)\n-\t\telif A.is_dir():shutil.rmtree(A);return aiohttp.web.Response(status=204)\n-\t\treturn aiohttp.web.Response(status=404,text='Not found')\n-\tasync def handle_mkcol(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D]\n-\t\tif B.exists():return aiohttp.web.Response(status=405,text='Directory already exists')\n-\t\tB.mkdir(parents=_E,exist_ok=_E);return aiohttp.web.Response(status=201,text='Directory created')\n-\tasync def handle_move(C,request):\n-\t\tA=request\n-\t\tif not await C.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];D=A[_A]/A.headers.get(_M,'').replace(_N,'')\n-\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n-\t\tshutil.move(str(B),str(D));return aiohttp.web.Response(status=201,text='Moved successfully')\n-\tasync def handle_copy(D,request):\n-\t\tA=request\n-\t\tif not await D.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tB=A[_A]/A.match_info[_D];C=A[_A]/A.headers.get(_M,'').replace(_N,'')\n-\t\tif not B.exists():return aiohttp.web.Response(status=404,text=_O)\n-\t\tif B.is_file():shutil.copy2(str(B),str(C))\n-\t\telse:shutil.copytree(str(B),str(C))\n-\t\treturn aiohttp.web.Response(status=201,text='Copied successfully')\n-\tasync def handle_options(B,request):A={'DAV':'1, 2','Allow':'OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH'};return aiohttp.web.Response(status=200,headers=A)\n-\tdef get_current_utc_time(C,filepath):\n-\t\tB=filepath\n-\t\tif B.exists():A=datetime.datetime.utcfromtimestamp(B.stat().st_mtime)\n-\t\telse:A=datetime.datetime.utcnow()\n-\t\treturn A.strftime('%Y-%m-%dT%H:%M:%SZ'),A.strftime(_P)\n-\t@time_cache_async(10)\n-\tasync def get_file_size(self,path):A=self.parent.loop;B=await A.run_in_executor(None,os.stat,path);return B.st_size\n-\t@time_cache_async(10)\n-\tasync def get_directory_size(self,directory):\n-\t\tA=0\n-\t\tfor(C,F,D)in os.walk(directory):\n-\t\t\tfor E in D:\n-\t\t\t\tB=pathlib.Path(C)/E\n-\t\t\t\tif B.exists():A+=await self.get_file_size(str(B))\n-\t\treturn A\n-\t@time_cache_async(30)\n-\tasync def get_disk_free_space(self,path='/'):B=self.parent.loop;A=await B.run_in_executor(None,os.statvfs,path);return A.f_bavail*A.f_frsize\n-\tasync def create_node(C,request,response_xml,full_path,depth):\n-\t\tif A.is_dir():etree.SubElement(Q,'{DAV:}collection')\n-\t\tR,S=C.get_current_utc_time(A);etree.SubElement(B,'{DAV:}creationdate').text=R;etree.SubElement(B,'{DAV:}quota-used-bytes').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A));etree.SubElement(B,'{DAV:}quota-available-bytes').text=str(await C.get_disk_free_space(E[_A]));etree.SubElement(B,'{DAV:}getlastmodified').text=S;etree.SubElement(B,'{DAV:}displayname').text=A.name;etree.SubElement(B,_R);T,Z=mimetypes.guess_type(A.name)\n-\t\tif A.is_file():etree.SubElement(B,'{DAV:}contenttype').text=T;etree.SubElement(B,'{DAV:}getcontentlength').text=str(await C.get_file_size(A)if A.is_file()else await C.get_directory_size(A))\n-\t\tL=etree.SubElement(B,'{DAV:}supportedlock');M=etree.SubElement(L,F);U=etree.SubElement(M,_H);etree.SubElement(U,_S);V=etree.SubElement(M,_I);etree.SubElement(V,_J);N=etree.SubElement(L,F);W=etree.SubElement(N,_H);etree.SubElement(W,'{DAV:}shared');X=etree.SubElement(N,_I);etree.SubElement(X,_J);etree.SubElement(K,'{DAV:}status').text='HTTP/1.1 200 OK'\n-\t\tif I.is_dir()and G>0:\n-\t\t\tfor Y in I.iterdir():await C.create_node(E,H,Y,G-1)\n-\tasync def handle_propfind(B,request):\n-\t\tA=request\n-\t\tif not await B.authenticate(A):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tC=0\n-\t\ttry:C=int(A.headers.get('Depth','0'))\n-\t\texcept ValueError:pass\n-\t\tF=A.match_info.get(_D,'');D=A[_A]/F\n-\t\tif not D.exists():return aiohttp.web.Response(status=404,text='Directory not found')\n-\t\tG={'D':'DAV:'};E=etree.Element('{DAV:}multistatus',nsmap=G);await B.create_node(A,E,D,C);H=etree.tostring(E,encoding='utf-8',xml_declaration=_E).decode();return aiohttp.web.Response(status=207,text=H,content_type=_T)\n-\tasync def handle_proppatch(A,request):\n-\t\tif not await A.authenticate(request):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\treturn aiohttp.web.Response(status=207,text='PROPPATCH OK (Not Implemented)')\n-\tasync def handle_lock(A,request):\n-\t\tC=request\n-\t\tif not await A.authenticate(C):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tD=C.match_info.get(_D,'/');B=str(uuid.uuid4());A.locks[D]=B;E=await A.generate_lock_response(B);F={_U:f\"opaquelocktoken:{B}\",_F:_T};return aiohttp.web.Response(text=E,headers=F,status=200)\n-\tasync def handle_unlock(A,request):\n-\t\tB=request\n-\t\tif not await A.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tC=B.match_info.get(_D,'/');D=B.headers.get(_U,'').replace('opaquelocktoken:','')[1:-1]\n-\t\tif A.locks.get(C)==D:del A.locks[C];return aiohttp.web.Response(status=204)\n-\t\treturn aiohttp.web.Response(status=400,text='Invalid Lock Token')\n-\tasync def generate_lock_response(J,lock_id):B=lock_id;D={'D':'DAV:'};C=etree.Element(_Q,nsmap=D);E=etree.SubElement(C,_R);A=etree.SubElement(E,'{DAV:}activelock');F=etree.SubElement(A,_I);etree.SubElement(F,_J);G=etree.SubElement(A,_H);etree.SubElement(G,_S);etree.SubElement(A,'{DAV:}depth').text='Infinity';H=etree.SubElement(A,'{DAV:}owner');etree.SubElement(H,_G).text=B;etree.SubElement(A,'{DAV:}timeout').text='Infinite';I=etree.SubElement(A,'{DAV:}locktoken');etree.SubElement(I,_G).text=f\"opaquelocktoken:{B}\";return etree.tostring(C,pretty_print=_E,encoding='utf-8').decode()\n-\tdef get_last_modified(C,path):\n-\t\tif not path.exists():return\n-\t\tA=path.stat().st_mtime;B=datetime.datetime.utcfromtimestamp(A);return B.strftime(_P)\n-\tasync def handle_head(D,request):\n-\t\tB=request\n-\t\tif not await D.authenticate(B):return aiohttp.web.Response(status=401,headers={_B:_C})\n-\t\tE=B.match_info.get(_D,'');A=B[_A]/E\n-\t\tif not A.exists():return aiohttp.web.Response(status=404,text=_K)\n-\t\tif A.is_dir():return aiohttp.web.Response(status=403,text='Cannot get metadata for a directory')\n-\t\tC,H=mimetypes.guess_type(str(A));C=C or _L;F=A.stat().st_size;G={_F:C,'Content-Length':str(F),'Last-Modified':D.get_last_modified(A)};return aiohttp.web.Response(status=200,headers=G)\n\\ No newline at end of file\n+    def __init__(self, parent, *args, **kwargs):\n+        middlewares = [debug_middleware]\n+\n+        super().__init__(middlewares=middlewares, *args, **kwargs)\n+        self.locks = {}\n+\n+        self.relative_url = \"/webdav\"\n+\n+        self.router.add_route(\"OPTIONS\", \"/{filename:.*}\", self.handle_options)\n+        self.router.add_route(\"GET\", \"/{filename:.*}\", self.handle_get)\n+        self.router.add_route(\"PUT\", \"/{filename:.*}\", self.handle_put)\n+        self.router.add_route(\"DELETE\", \"/{filename:.*}\", self.handle_delete)\n+        self.router.add_route(\"MKCOL\", \"/{filename:.*}\", self.handle_mkcol)\n+        self.router.add_route(\"MOVE\", \"/{filename:.*}\", self.handle_move)\n+        self.router.add_route(\"COPY\", \"/{filename:.*}\", self.handle_copy)\n+        self.router.add_route(\"PROPFIND\", \"/{filename:.*}\", self.handle_propfind)\n+        self.router.add_route(\"PROPPATCH\", \"/{filename:.*}\", self.handle_proppatch)\n+        self.router.add_route(\"LOCK\", \"/{filename:.*}\", self.handle_lock)\n+        self.router.add_route(\"UNLOCK\", \"/{filename:.*}\", self.handle_unlock)\n+        self.parent = parent\n+\n+    @property\n+    def db(self):\n+        return self.parent.db\n+\n+    @property\n+    def services(self):\n+        return self.parent.services\n+\n+    async def authenticate(self, request):\n+        auth_header = request.headers.get(\"Authorization\", \"\")\n+        if not auth_header.startswith(\"Basic \"):\n+            return False\n+        encoded_creds = auth_header.split(\"Basic \")[1]\n+        decoded_creds = base64.b64decode(encoded_creds).decode()\n+        username, password = decoded_creds.split(\":\", 1)\n+        request[\"user\"] = await self.services.user.authenticate(\n+            username=username, password=password\n+        )\n+        try:\n+            request[\"home\"] = await self.services.user.get_home_folder(\n+                request[\"user\"][\"uid\"]\n+            )\n+        except Exception:\n+            pass\n+        return request[\"user\"]\n+\n+    async def handle_get(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(status=403, text=\"Cannot download a directory\")\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+\n+        return aiohttp.web.FileResponse(\n+            path=str(abs_path), headers={\"Content-Type\": content_type}, chunk_size=8192\n+        )\n+\n+    async def handle_put(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        file_path.parent.mkdir(parents=True, exist_ok=True)\n+        async with aiofiles.open(file_path, \"wb\") as f:\n+            while chunk := await request.content.read(1024):\n+                await f.write(chunk)\n+        return aiohttp.web.Response(status=201, text=\"File uploaded\")\n+\n+    async def handle_delete(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        file_path = request[\"home\"] / request.match_info[\"filename\"]\n+        if file_path.is_file():\n+            file_path.unlink()\n+            return aiohttp.web.Response(status=204)\n+        elif file_path.is_dir():\n+            shutil.rmtree(file_path)\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=404, text=\"Not found\")\n+\n+    async def handle_mkcol(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        dir_path = request[\"home\"] / request.match_info[\"filename\"]\n+        if dir_path.exists():\n+            return aiohttp.web.Response(status=405, text=\"Directory already exists\")\n+        dir_path.mkdir(parents=True, exist_ok=True)\n+        return aiohttp.web.Response(status=201, text=\"Directory created\")\n+\n+    async def handle_move(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        shutil.move(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Moved successfully\")\n+\n+    async def handle_copy(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        src_path = request[\"home\"] / request.match_info[\"filename\"]\n+        dest_path = request[\"home\"] / request.headers.get(\"Destination\", \"\").replace(\n+        )\n+        if not src_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Source not found\")\n+        if src_path.is_file():\n+            shutil.copy2(str(src_path), str(dest_path))\n+        else:\n+            shutil.copytree(str(src_path), str(dest_path))\n+        return aiohttp.web.Response(status=201, text=\"Copied successfully\")\n+\n+    async def handle_options(self, request):\n+        headers = {\n+            \"DAV\": \"1, 2\",\n+            \"Allow\": \"OPTIONS, GET, PUT, DELETE, MKCOL, MOVE, COPY, PROPFIND, PROPPATCH\",\n+        }\n+        return aiohttp.web.Response(status=200, headers=headers)\n+\n+    def get_current_utc_time(self, filepath):\n+        if filepath.exists():\n+            modified_time = datetime.datetime.utcfromtimestamp(filepath.stat().st_mtime)\n+        else:\n+            modified_time = datetime.datetime.utcnow()\n+        return modified_time.strftime(\"%Y-%m-%dT%H:%M:%SZ\"), modified_time.strftime(\n+            \"%a, %d %b %Y %H:%M:%S GMT\"\n+        )\n+\n+    @time_cache_async(10)\n+    async def get_file_size(self, path):\n+        loop = self.parent.loop\n+        stat = await loop.run_in_executor(None, os.stat, path)\n+        return stat.st_size\n+\n+    @time_cache_async(10)\n+    async def get_directory_size(self, directory):\n+        total_size = 0\n+        for dirpath, _, filenames in os.walk(directory):\n+            for f in filenames:\n+                fp = pathlib.Path(dirpath) / f\n+                if fp.exists():\n+                    total_size += await self.get_file_size(str(fp))\n+        return total_size\n+\n+    @time_cache_async(30)\n+    async def get_disk_free_space(self, path=\"/\"):\n+        loop = self.parent.loop\n+        statvfs = await loop.run_in_executor(None, os.statvfs, path)\n+        return statvfs.f_bavail * statvfs.f_frsize\n+\n+    async def create_node(self, request, response_xml, full_path, depth):\n+        abs_path = pathlib.Path(full_path)\n+        relative_path = str(full_path.relative_to(request[\"home\"]))\n+\n+        href_path = f\"{self.relative_url}/{relative_path}\".strip(\".\")\n+        href_path = href_path.replace(\"./\", \"/\")\n+\n+        response = etree.SubElement(response_xml, \"{DAV:}response\")\n+        href = etree.SubElement(response, \"{DAV:}href\")\n+        href.text = href_path\n+        propstat = etree.SubElement(response, \"{DAV:}propstat\")\n+        prop = etree.SubElement(propstat, \"{DAV:}prop\")\n+        res_type = etree.SubElement(prop, \"{DAV:}resourcetype\")\n+        if full_path.is_dir():\n+            etree.SubElement(res_type, \"{DAV:}collection\")\n+        creation_date, last_modified = self.get_current_utc_time(full_path)\n+        etree.SubElement(prop, \"{DAV:}creationdate\").text = creation_date\n+        etree.SubElement(prop, \"{DAV:}quota-used-bytes\").text = str(\n+            await self.get_file_size(full_path)\n+            if full_path.is_file()\n+            else await self.get_directory_size(full_path)\n+        )\n+        etree.SubElement(prop, \"{DAV:}quota-available-bytes\").text = str(\n+            await self.get_disk_free_space(request[\"home\"])\n+        )\n+        etree.SubElement(prop, \"{DAV:}getlastmodified\").text = last_modified\n+        etree.SubElement(prop, \"{DAV:}displayname\").text = full_path.name\n+        etree.SubElement(prop, \"{DAV:}lockdiscovery\")\n+        mimetype, _ = mimetypes.guess_type(full_path.name)\n+        if full_path.is_file():\n+            etree.SubElement(prop, \"{DAV:}contenttype\").text = mimetype\n+            etree.SubElement(prop, \"{DAV:}getcontentlength\").text = str(\n+                await self.get_file_size(full_path)\n+                if full_path.is_file()\n+                else await self.get_directory_size(full_path)\n+            )\n+        supported_lock = etree.SubElement(prop, \"{DAV:}supportedlock\")\n+        lock_entry_1 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_1 = etree.SubElement(lock_entry_1, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_1, \"{DAV:}exclusive\")\n+        lock_type_1 = etree.SubElement(lock_entry_1, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_1, \"{DAV:}write\")\n+        lock_entry_2 = etree.SubElement(supported_lock, \"{DAV:}lockentry\")\n+        lock_scope_2 = etree.SubElement(lock_entry_2, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope_2, \"{DAV:}shared\")\n+        lock_type_2 = etree.SubElement(lock_entry_2, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type_2, \"{DAV:}write\")\n+        etree.SubElement(propstat, \"{DAV:}status\").text = \"HTTP/1.1 200 OK\"\n+\n+        if abs_path.is_dir() and depth > 0:\n+            for item in abs_path.iterdir():\n+                await self.create_node(request, response_xml, item, depth - 1)\n+\n+    async def handle_propfind(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        depth = 0\n+        try:\n+            depth = int(request.headers.get(\"Depth\", \"0\"))\n+        except ValueError:\n+            pass\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+\n+        abs_path = request[\"home\"] / requested_path\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"Directory not found\")\n+        nsmap = {\"D\": \"DAV:\"}\n+        response_xml = etree.Element(\"{DAV:}multistatus\", nsmap=nsmap)\n+\n+        await self.create_node(request, response_xml, abs_path, depth)\n+\n+        xml_output = etree.tostring(\n+            response_xml, encoding=\"utf-8\", xml_declaration=True\n+        ).decode()\n+        return aiohttp.web.Response(\n+            status=207, text=xml_output, content_type=\"application/xml\"\n+        )\n+\n+    async def handle_proppatch(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        return aiohttp.web.Response(status=207, text=\"PROPPATCH OK (Not Implemented)\")\n+\n+    async def handle_lock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_id = str(uuid.uuid4())\n+        self.locks[resource] = lock_id\n+        xml_response = await self.generate_lock_response(lock_id)\n+        headers = {\n+            \"Lock-Token\": f\"opaquelocktoken:{lock_id}\",\n+            \"Content-Type\": \"application/xml\",\n+        }\n+        return aiohttp.web.Response(text=xml_response, headers=headers, status=200)\n+\n+    async def handle_unlock(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+        resource = request.match_info.get(\"filename\", \"/\")\n+        lock_token = request.headers.get(\"Lock-Token\", \"\").replace(\n+            \"opaquelocktoken:\", \"\"\n+        )[1:-1]\n+        if self.locks.get(resource) == lock_token:\n+            del self.locks[resource]\n+            return aiohttp.web.Response(status=204)\n+        return aiohttp.web.Response(status=400, text=\"Invalid Lock Token\")\n+\n+    async def generate_lock_response(self, lock_id):\n+        nsmap = {\"D\": \"DAV:\"}\n+        root = etree.Element(\"{DAV:}prop\", nsmap=nsmap)\n+        lock_discovery = etree.SubElement(root, \"{DAV:}lockdiscovery\")\n+        active_lock = etree.SubElement(lock_discovery, \"{DAV:}activelock\")\n+        lock_type = etree.SubElement(active_lock, \"{DAV:}locktype\")\n+        etree.SubElement(lock_type, \"{DAV:}write\")\n+        lock_scope = etree.SubElement(active_lock, \"{DAV:}lockscope\")\n+        etree.SubElement(lock_scope, \"{DAV:}exclusive\")\n+        etree.SubElement(active_lock, \"{DAV:}depth\").text = \"Infinity\"\n+        owner = etree.SubElement(active_lock, \"{DAV:}owner\")\n+        etree.SubElement(owner, \"{DAV:}href\").text = lock_id\n+        etree.SubElement(active_lock, \"{DAV:}timeout\").text = \"Infinite\"\n+        lock_token = etree.SubElement(active_lock, \"{DAV:}locktoken\")\n+        etree.SubElement(lock_token, \"{DAV:}href\").text = f\"opaquelocktoken:{lock_id}\"\n+        return etree.tostring(root, pretty_print=True, encoding=\"utf-8\").decode()\n+\n+    def get_last_modified(self, path):\n+        if not path.exists():\n+            return None\n+        timestamp = path.stat().st_mtime\n+        dt = datetime.datetime.utcfromtimestamp(timestamp)\n+        return dt.strftime(\"%a, %d %b %Y %H:%M:%S GMT\")\n+\n+    async def handle_head(self, request):\n+        if not await self.authenticate(request):\n+            return aiohttp.web.Response(\n+                status=401, headers={\"WWW-Authenticate\": 'Basic realm=\"WebDAV\"'}\n+            )\n+\n+        requested_path = request.match_info.get(\"filename\", \"\")\n+        abs_path = request[\"home\"] / requested_path\n+\n+        if not abs_path.exists():\n+            return aiohttp.web.Response(status=404, text=\"File not found\")\n+\n+        if abs_path.is_dir():\n+            return aiohttp.web.Response(\n+                status=403, text=\"Cannot get metadata for a directory\"\n+            )\n+\n+        content_type, _ = mimetypes.guess_type(str(abs_path))\n+        content_type = content_type or \"application/octet-stream\"\n+        file_size = abs_path.stat().st_size\n+\n+        headers = {\n+            \"Content-Type\": content_type,\n+            \"Content-Length\": str(file_size),\n+            \"Last-Modified\": self.get_last_modified(abs_path),\n+        }\n+\n+        return aiohttp.web.Response(status=200, headers=headers)\ndiff --git a/src/snekssh/app.py b/src/snekssh/app.py\nindex a2087be..aab17e4 100644\n--- a/src/snekssh/app.py\n+++ b/src/snekssh/app.py\n@@ -1,25 +1,78 @@\n-_A=True\n-import asyncio,logging,os,asyncssh\n+import asyncio\n+import logging\n+import os\n+\n+import asyncssh\n+\n asyncssh.set_debug_level(2)\n logging.basicConfig(level=logging.DEBUG)\n-SFTP_ROOT='.'\n-USERNAME='test'\n-PASSWORD='woeii'\n-HOST='localhost'\n-PORT=2225\n+USERNAME = \"test\"\n+PASSWORD = \"woeii\"\n+HOST = \"localhost\"\n+PORT = 2225\n+\n+\n class MySFTPServer(asyncssh.SFTPServer):\n-\tdef __init__(A,chan):super().__init__(chan);A.root=os.path.abspath(SFTP_ROOT)\n-\tasync def stat(A,path):\"Handles 'stat' command from SFTP client\";B=os.path.join(A.root,path.lstrip('/'));return await super().stat(B)\n-\tasync def open(A,path,flags,attrs):'Handles file open requests';B=os.path.join(A.root,path.lstrip('/'));return await super().open(B,flags,attrs)\n-\tasync def listdir(A,path):'Handles directory listing';B=os.path.join(A.root,path.lstrip('/'));return await super().listdir(B)\n+    def __init__(self, chan):\n+        super().__init__(chan)\n+        self.root = os.path.abspath(SFTP_ROOT)\n+\n+    async def stat(self, path):\n+        \"\"\"Handles 'stat' command from SFTP client\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().stat(full_path)\n+\n+    async def open(self, path, flags, attrs):\n+        \"\"\"Handles file open requests\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().open(full_path, flags, attrs)\n+\n+    async def listdir(self, path):\n+        \"\"\"Handles directory listing\"\"\"\n+        full_path = os.path.join(self.root, path.lstrip(\"/\"))\n+        return await super().listdir(full_path)\n+\n+\n class MySSHServer(asyncssh.SSHServer):\n-\t'Custom SSH server to handle authentication'\n-\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n-\tdef connection_lost(A,exc):print('Client disconnected')\n-\tdef begin_auth(A,username):return _A\n-\tdef password_auth_supported(A):return _A\n-\tdef validate_password(C,username,password):A=password;B=username;print(B,A);return _A;return B==USERNAME and A==PASSWORD\n-async def start_sftp_server():os.makedirs(SFTP_ROOT,exist_ok=_A);await asyncssh.create_server(lambda:MySSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=MySFTPServer);print(f\"SFTP server running on {HOST}:{PORT}\");await asyncio.Future()\n-if __name__=='__main__':\n-\ttry:asyncio.run(start_sftp_server())\n-\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SFTP server: {e}\")\n\\ No newline at end of file\n+    \"\"\"Custom SSH server to handle authentication\"\"\"\n+\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def begin_auth(self, username):\n+\n+    def password_auth_supported(self):\n+\n+    def validate_password(self, username, password):\n+        print(username, password)\n+\n+        return True\n+        return username == USERNAME and password == PASSWORD\n+\n+\n+async def start_sftp_server():\n+\n+    await asyncssh.create_server(\n+        lambda: MySSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=MySFTPServer,\n+    )\n+    print(f\"SFTP server running on {HOST}:{PORT}\")\n+\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_sftp_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SFTP server: {e}\")\ndiff --git a/src/snekssh/app2.py b/src/snekssh/app2.py\nindex 8879a05..2fa26a7 100644\n--- a/src/snekssh/app2.py\n+++ b/src/snekssh/app2.py\n@@ -1,28 +1,77 @@\n-import asyncio,os,asyncssh\n-HOST='0.0.0.0'\n-PORT=2225\n-USERNAME='user'\n-PASSWORD='password'\n-SHELL='/bin/sh'\n+import asyncio\n+import os\n+\n+import asyncssh\n+\n+HOST = \"0.0.0.0\"\n+PORT = 2225\n+USERNAME = \"user\"\n+PASSWORD = \"password\"\n+\n+\n class CustomSSHServer(asyncssh.SSHServer):\n-\tdef connection_made(A,conn):print(f\"New connection from {conn.get_extra_info(\"peername\")}\")\n-\tdef connection_lost(A,exc):print('Client disconnected')\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(A,username,password):return username==USERNAME and password==PASSWORD\n+    def connection_made(self, conn):\n+        print(f\"New connection from {conn.get_extra_info('peername')}\")\n+\n+    def connection_lost(self, exc):\n+        print(\"Client disconnected\")\n+\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        return username == USERNAME and password == PASSWORD\n+\n+\n async def custom_bash_process(process):\n-\t'Spawns a custom bash shell process';A=process;B=os.environ.copy();B['TERM']='xterm-256color';C=await asyncio.create_subprocess_exec(SHELL,'-i',stdin=asyncio.subprocess.PIPE,stdout=asyncio.subprocess.PIPE,stderr=asyncio.subprocess.PIPE,env=B)\n-\tasync def D():\n-\t\twhile True:\n-\t\t\tB=await C.stdout.read(1)\n-\t\t\tif not B:break\n-\t\t\tA.stdout.write(B)\n-\tasync def E():\n-\t\twhile True:\n-\t\t\tB=await A.stdin.read(1)\n-\t\t\tif not B:break\n-\t\t\tC.stdin.write(B)\n-\tawait asyncio.gather(D(),E())\n-async def start_ssh_server():'Starts the AsyncSSH server with Bash';await asyncssh.create_server(lambda:CustomSSHServer(),host=HOST,port=PORT,server_host_keys=['ssh_host_key'],process_factory=custom_bash_process);print(f\"SSH server running on {HOST}:{PORT}\");await asyncio.Future()\n-if __name__=='__main__':\n-\ttry:asyncio.run(start_ssh_server())\n-\texcept(OSError,asyncssh.Error)as e:print(f\"Error starting SSH server: {e}\")\n\\ No newline at end of file\n+    \"\"\"Spawns a custom bash shell process\"\"\"\n+    env = os.environ.copy()\n+    env[\"TERM\"] = \"xterm-256color\"\n+\n+    bash_proc = await asyncio.create_subprocess_exec(\n+        SHELL,\n+        \"-i\",\n+        stdin=asyncio.subprocess.PIPE,\n+        stdout=asyncio.subprocess.PIPE,\n+        stderr=asyncio.subprocess.PIPE,\n+        env=env,\n+    )\n+\n+    async def read_output():\n+        while True:\n+            data = await bash_proc.stdout.read(1)\n+            if not data:\n+                break\n+            process.stdout.write(data)\n+\n+    async def read_input():\n+        while True:\n+            data = await process.stdin.read(1)\n+            if not data:\n+                break\n+            bash_proc.stdin.write(data)\n+\n+    await asyncio.gather(read_output(), read_input())\n+\n+\n+async def start_ssh_server():\n+    \"\"\"Starts the AsyncSSH server with Bash\"\"\"\n+    await asyncssh.create_server(\n+        lambda: CustomSSHServer(),\n+        host=HOST,\n+        port=PORT,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=custom_bash_process,\n+    )\n+    print(f\"SSH server running on {HOST}:{PORT}\")\n+\n+\n+if __name__ == \"__main__\":\n+    try:\n+        asyncio.run(start_ssh_server())\n+    except (OSError, asyncssh.Error) as e:\n+        print(f\"Error starting SSH server: {e}\")\ndiff --git a/src/snekssh/app3.py b/src/snekssh/app3.py\nindex ef35691..4a09452 100644\n--- a/src/snekssh/app3.py\n+++ b/src/snekssh/app3.py\n@@ -1,17 +1,74 @@\n-import asyncio,sys,asyncssh\n-async def handle_client(process):\n-\tA=process;E,F,C,D=A.term_size;A.stdout.write(f\"Terminal type: {A.term_type}, size: {E}x{F}\")\n-\tif C and D:A.stdout.write(f\" ({C}x{D} pixels)\")\n-\tA.stdout.write('\\nTry resizing your window!\\n')\n-\twhile not A.stdin.at_eof():\n-\t\ttry:await A.stdin.read()\n-\t\texcept asyncssh.TerminalSizeChanged as B:\n-\t\t\tA.stdout.write(f\"New window size: {B.width}x{B.height}\")\n-\t\t\tif B.pixwidth and B.pixheight:A.stdout.write(f\" ({B.pixwidth}x{B.pixheight} pixels)\")\n-\t\t\tA.stdout.write('\\n')\n-async def start_server():await asyncssh.listen('',2230,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+\n+\n+import asyncio\n+import sys\n+\n+import asyncssh\n+\n+\n+async def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+\n+    width, height, pixwidth, pixheight = process.term_size\n+\n+    process.stdout.write(\n+        f\"Terminal type: {process.term_type}, \" f\"size: {width}x{height}\"\n+    )\n+    if pixwidth and pixheight:\n+        process.stdout.write(f\" ({pixwidth}x{pixheight} pixels)\")\n+    process.stdout.write(\"\\nTry resizing your window!\\n\")\n+\n+    while not process.stdin.at_eof():\n+        try:\n+            await process.stdin.read()\n+        except asyncssh.TerminalSizeChanged as exc:\n+            process.stdout.write(f\"New window size: {exc.width}x{exc.height}\")\n+            if exc.pixwidth and exc.pixheight:\n+                process.stdout.write(f\" ({exc.pixwidth}\" f\"x{exc.pixheight} pixels)\")\n+            process.stdout.write(\"\\n\")\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen(\n+        \"\",\n+        2230,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app4.py b/src/snekssh/app4.py\nindex eb4fe72..187722c 100644\n--- a/src/snekssh/app4.py\n+++ b/src/snekssh/app4.py\n@@ -1,24 +1,90 @@\n-import asyncio,sys\n+\n+\n+import asyncio\n+import sys\n from typing import Optional\n-import asyncssh,bcrypt\n-passwords={'guest':b'','user':bcrypt.hashpw(b'user',bcrypt.gensalt())}\n-def handle_client(process):A=process;B=A.get_extra_info('username');A.stdout.write(f\"Welcome to my SSH server, {B}!\\n\")\n+\n+import asyncssh\n+import bcrypt\n+\n+passwords = {\n+    \"user\": bcrypt.hashpw(b\"user\", bcrypt.gensalt()),\n+}\n+\n+\n+def handle_client(process: asyncssh.SSHServerProcess) -> None:\n+    username = process.get_extra_info(\"username\")\n+    process.stdout.write(f\"Welcome to my SSH server, {username}!\\n\")\n+\n+\n class MySSHServer(asyncssh.SSHServer):\n-\tdef connection_made(B,conn):A=conn.get_extra_info('peername')[0];print(f\"SSH connection received from {A}.\")\n-\tdef connection_lost(A,exc):\n-\t\tif exc:print('SSH connection error: '+str(exc),file=sys.stderr)\n-\t\telse:print('SSH connection closed.')\n-\tdef begin_auth(A,username):return passwords.get(username)!=b''\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(D,username,password):\n-\t\tA=password;B=username\n-\t\tif B not in passwords:return False\n-\t\tC=passwords[B]\n-\t\tif not A and not C:return True\n-\t\treturn bcrypt.checkpw(A.encode('utf-8'),C)\n-async def start_server():await asyncssh.create_server(MySSHServer,'',2231,server_host_keys=['ssh_host_key'],process_factory=handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+    def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:\n+        peername = conn.get_extra_info(\"peername\")[0]\n+        print(f\"SSH connection received from {peername}.\")\n+\n+    def connection_lost(self, exc: Optional[Exception]) -> None:\n+        if exc:\n+            print(\"SSH connection error: \" + str(exc), file=sys.stderr)\n+        else:\n+            print(\"SSH connection closed.\")\n+\n+    def begin_auth(self, username: str) -> bool:\n+        return passwords.get(username) != b\"\"\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        if username not in passwords:\n+            return False\n+        pw = passwords[username]\n+        if not password and not pw:\n+            return True\n+        return bcrypt.checkpw(password.encode(\"utf-8\"), pw)\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.create_server(\n+        MySSHServer,\n+        \"\",\n+        2231,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()\ndiff --git a/src/snekssh/app5.py b/src/snekssh/app5.py\nindex 39f45e3..cfd5d21 100644\n--- a/src/snekssh/app5.py\n+++ b/src/snekssh/app5.py\n@@ -1,28 +1,112 @@\n-import asyncio,sys\n-from typing import List,cast\n+\n+\n+import asyncio\n+import sys\n+from typing import List, cast\n+\n import asyncssh\n+\n+\n class ChatClient:\n-\t_clients:List['ChatClient']=[]\n-\tdef __init__(A,process):A._process=process\n-\t@classmethod\n-\tasync def handle_client(A,process):await A(process).run()\n-\tasync def readline(A):return cast(str,A._process.stdin.readline())\n-\tdef write(A,msg):A._process.stdout.write(msg)\n-\tdef broadcast(A,msg):\n-\t\tfor B in A._clients:\n-\t\t\tif B!=A:B.write(msg)\n-\tdef begin_auth(A,username):return True\n-\tdef password_auth_supported(A):return True\n-\tdef validate_password(A,username,password):return True\n-\tasync def run(A):\n-\t\tA.write('Welcome to chat!\\n\\n');A.write('Enter your name: ');B=(await A.readline()).rstrip('\\n');A.write(f\"\\n{len(A._clients)} other users are connected.\\n\\n\");A._clients.append(A);A.broadcast(f\"*** {B} has entered chat ***\\n\")\n-\t\ttry:\n-\t\t\tasync for C in A._process.stdin:A.broadcast(f\"{B}: {C}\")\n-\t\texcept asyncssh.BreakReceived:pass\n-\t\tA.broadcast(f\"*** {B} has left chat ***\\n\");A._clients.remove(A)\n-async def start_server():await asyncssh.listen('',2235,server_host_keys=['ssh_host_key'],process_factory=ChatClient.handle_client)\n-loop=asyncio.new_event_loop()\n-try:loop.run_until_complete(start_server())\n-except(OSError,asyncssh.Error)as exc:sys.exit('Error starting server: '+str(exc))\n-loop.run_forever()\n\\ No newline at end of file\n+    _clients: List[\"ChatClient\"] = []\n+\n+    def __init__(self, process: asyncssh.SSHServerProcess):\n+        self._process = process\n+\n+    @classmethod\n+    async def handle_client(cls, process: asyncssh.SSHServerProcess):\n+        await cls(process).run()\n+\n+    async def readline(self) -> str:\n+        return cast(str, self._process.stdin.readline())\n+\n+    def write(self, msg: str) -> None:\n+        self._process.stdout.write(msg)\n+\n+    def broadcast(self, msg: str) -> None:\n+        for client in self._clients:\n+            if client != self:\n+                client.write(msg)\n+\n+    def begin_auth(self, username: str) -> bool:\n+\n+    def password_auth_supported(self) -> bool:\n+        return True\n+\n+    def validate_password(self, username: str, password: str) -> bool:\n+        return True\n+\n+    async def run(self) -> None:\n+        self.write(\"Welcome to chat!\\n\\n\")\n+\n+        self.write(\"Enter your name: \")\n+        name = (await self.readline()).rstrip(\"\\n\")\n+\n+        self.write(f\"\\n{len(self._clients)} other users are connected.\\n\\n\")\n+\n+        self._clients.append(self)\n+        self.broadcast(f\"*** {name} has entered chat ***\\n\")\n+\n+        try:\n+            async for line in self._process.stdin:\n+                self.broadcast(f\"{name}: {line}\")\n+        except asyncssh.BreakReceived:\n+            pass\n+\n+        self.broadcast(f\"*** {name} has left chat ***\\n\")\n+        self._clients.remove(self)\n+\n+\n+async def start_server() -> None:\n+    await asyncssh.listen(\n+        \"\",\n+        2235,\n+        server_host_keys=[\"ssh_host_key\"],\n+        process_factory=ChatClient.handle_client,\n+    )\n+\n+\n+loop = asyncio.new_event_loop()\n+\n+try:\n+    loop.run_until_complete(start_server())\n+except (OSError, asyncssh.Error) as exc:\n+    sys.exit(\"Error starting server: \" + str(exc))\n+\n+loop.run_forever()"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Update dependencies and refactor repository view for improved navigation and file handling", "commit": "44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8", "diff": "commit 44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 15:55:51 2025 +0200\n\n    Update.\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex b6f1688..cc84391 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,7 +33,8 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n-    \"uvloop\"\n+    \"uvloop\",\n+    \"humanize\"\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex ceb7c9d..3e4ad5e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -182,8 +182,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}\", RepositoryView)\n-        self.router.add_view(\"/repository/{username}/{repo_name}/{rel_path:.*}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repository}\", RepositoryView)\n+        self.router.add_view(\"/repository/{username}/{repository}/{path:.*}\", RepositoryView)\n         self.router.add_view(\"/settings/repositories/index.html\", RepositoriesIndexView)\n         self.router.add_view(\"/settings/repositories/create.html\", RepositoriesCreateView)\n         self.router.add_view(\"/settings/repositories/repository/{name}/update.html\", RepositoriesUpdateView)\ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nindex f7a2e9d..0c19142 100644\n--- a/src/snek/view/repository.py\n+++ b/src/snek/view/repository.py\n@@ -1,15 +1,265 @@\n-from snek.system.view import BaseView\n+import os\n+import mimetypes\n+import urllib.parse\n+from pathlib import Path\n+import humanize\n from aiohttp import web\n+from snek.system.view import BaseView\n+import asyncio \n+from git import Repo\n+\n+\n+\n+\n+class BareRepoNavigator:\n+    def __init__(self, repo_path):\n+        \"\"\"Initialize the navigator with a bare repository path.\"\"\"\n+        try:\n+            self.repo = Repo(repo_path)\n+            if not self.repo.bare:\n+                print(f\"Error: {repo_path} is not a bare repository.\")\n+                sys.exit(1)\n+        except git.exc.InvalidGitRepositoryError:\n+            print(f\"Error: {repo_path} is not a valid Git repository.\")\n+            sys.exit(1)\n+        except Exception as e:\n+            print(f\"Error opening repository: {str(e)}\")\n+            sys.exit(1)\n+            \n+        self.repo_path = repo_path\n+        self.branches = list(self.repo.branches)\n+        self.current_branch = None\n+        self.current_commit = None\n+        self.current_path = \"\"\n+        self.history = []\n+        \n+    def get_branches(self):\n+        \"\"\"Return a list of branch names in the repository.\"\"\"\n+        return [branch.name for branch in self.branches]\n+    \n+    def set_branch(self, branch_name):\n+        \"\"\"Set the current branch.\"\"\"\n+        try:\n+            self.current_branch = self.repo.branches[branch_name]\n+            self.current_commit = self.current_branch.commit\n+            self.current_path = \"\"\n+            self.history = []\n+            return True\n+        except IndexError:\n+            return False\n+            \n+    def get_commits(self, count=10):\n+        \"\"\"Get the latest commits on the current branch.\"\"\"\n+        if not self.current_branch:\n+            return []\n+            \n+        commits = []\n+        for commit in self.repo.iter_commits(self.current_branch, max_count=count):\n+            commits.append({\n+                'hash': commit.hexsha,\n+                'short_hash': commit.hexsha[:7],\n+                'message': commit.message.strip(),\n+                'author': commit.author.name,\n+                'date': datetime.fromtimestamp(commit.committed_date).strftime('%Y-%m-%d %H:%M:%S')\n+            })\n+        return commits\n+        \n+    def set_commit(self, commit_hash):\n+        \"\"\"Set the current commit by hash.\"\"\"\n+        try:\n+            self.current_commit = self.repo.commit(commit_hash)\n+            self.current_path = \"\"\n+            self.history = []\n+            return True\n+        except ValueError:\n+            return False\n+            \n+    def list_directory(self, path=\"\"):\n+        \"\"\"List the contents of a directory in the current commit.\"\"\"\n+        if not self.current_commit:\n+            return {'dirs': [], 'files': []}\n+            \n+        dirs = []\n+        files = []\n+        \n+        try:\n+            if path:\n+                tree = self.current_commit.tree[path]\n+                    return {'dirs': [], 'files': [path]}\n+            else:\n+                tree = self.current_commit.tree\n+                \n+            for item in tree:\n+                if item.type == 'tree':\n+                    item_path = os.path.join(path, item.name) if path else item.name\n+                    dirs.append(item_path)\n+                elif item.type == 'blob':\n+                    item_path = os.path.join(path, item.name) if path else item.name\n+                    files.append(item_path)\n+                    \n+            dirs.sort()\n+            files.sort()\n+            return {'dirs': dirs, 'files': files}\n+            \n+        except KeyError:\n+            return {'dirs': [], 'files': []}\n+            \n+    def get_file_content(self, file_path):\n+        \"\"\"Get the content of a file in the current commit.\"\"\"\n+        if not self.current_commit:\n+            return None\n+            \n+        try:\n+            blob = self.current_commit.tree[file_path]\n+            return blob.data_stream.read().decode('utf-8', errors='replace')\n+        except (KeyError, UnicodeDecodeError):\n+            try:\n+                blob = self.current_commit.tree[file_path]\n+                return blob.data_stream.read()\n+            except:\n+                return None\n+                \n+    def navigate_to(self, path):\n+        \"\"\"Navigate to a specific path, updating the current path.\"\"\"\n+        if not self.current_commit:\n+            return False\n+            \n+        try:\n+            if path:\n+            self.history.append(self.current_path)\n+            self.current_path = path\n+            return True\n+        except KeyError:\n+            return False\n+            \n+    def navigate_back(self):\n+        \"\"\"Navigate back to the previous path.\"\"\"\n+        if self.history:\n+            self.current_path = self.history.pop()\n+            return True\n+        return False\n+\n+\n+\n class RepositoryView(BaseView):\n-\tasync def get(A):\n-\t\tG='type';H='name';I='.git';J='username';B=A.request.match_info[J];K=A.request.match_info['repo_name'];C=A.request.match_info.get('rel_path','')\n-\t\tif not B.count('-')==4:E=await A.services.user.get_by_username(B)\n-\t\telse:E=await A.services.user.get(B)\n-\t\tif not E:return web.HTTPNotFound()\n-\t\tB=E[J];M=await A.services.user.get_repository_path(E['uid'])\n-\t\tif C.endswith(I):C=C[:-4]\n-\t\tL=M.joinpath(K+I)\n-\t\tif not L.exists():return web.HTTPNotFound()\n-\t\timport os;from git import Repo;N=Repo(L.joinpath(C));F=[];O=[];P=N.head.commit\n-\t\tfor D in P.tree.traverse():F.append({H:D.name,'mode':D.mode,G:D.type,'path':D.path,'size':D.size})\n-\t\tsorted(F,key=lambda x:x[H]);sorted(F,key=lambda x:x[G],reverse=True);Q=f\"{B}/{C}\"[:-4];return await A.render_template('repository.html',dict(username=B,repo_name=K,rel_path=C,full_path=Q,files=F,directories=O))\n\\ No newline at end of file\n+\n+    login_required = True\n+\n+    def checkout_bare_repo(self, bare_repo_path: Path, target_path: Path, ref: str = 'HEAD'):\n+        repo = Repo(bare_repo_path)\n+        assert repo.bare, \"Repository is not bare.\"\n+\n+        commit = repo.commit(ref)\n+        tree = commit.tree\n+\n+        for blob in tree.traverse():\n+            target_file = target_path / blob.path\n+                \n+            target_file.parent.mkdir(parents=True, exist_ok=True)\n+            print(blob.path)\n+\n+            with open(target_file, 'wb') as f:\n+                f.write(blob.data_stream.read())\n+\n+\n+    async def get(self):\n+\n+        base_repo_path = Path(\"drive/repositories\")  \n+\n+        authenticated_user_id = self.session.get(\"uid\")\n+\n+        username = self.request.match_info.get('username')\n+        repo_name = self.request.match_info.get('repository')\n+        rel_path = self.request.match_info.get('path', '')\n+        user = None\n+        if not username.count(\"-\") == 4:\n+            user = await self.app.services.user.get(username=username)\n+            if not user:\n+                return web.Response(text=\"404 Not Found\", status=404)\n+            username = user[\"username\"]\n+        else:\n+            user = await self.app.services.user.get(uid=username)\n+\n+        repo = await self.app.services.repository.get(name=repo_name, user_uid=user[\"uid\"])\n+        if not repo:\n+            return web.Response(text=\"404 Not Found\", status=404)\n+        if repo['is_private'] and authenticated_user_id != repo['uid']:  \n+            return web.Response(text=\"404 Not Found\", status=404) \n+\n+        repo_root_base = (base_repo_path / user['uid'] / (repo_name + \".git\")).resolve()\n+        repo_root = (base_repo_path / user['uid'] / repo_name).resolve()\n+        try:\n+            loop = asyncio.get_event_loop()\n+            await loop.run_in_executor(None,\n+                         self.checkout_bare_repo, repo_root_base, repo_root\n+            )\n+        except:\n+            pass\n+        \n+        if not repo_root.exists() or not repo_root.is_dir():\n+            return web.Response(text=\"404 Not Found\", status=404)\n+\n+        safe_rel_path = os.path.normpath(rel_path).lstrip(os.sep)\n+        abs_path = (repo_root / safe_rel_path).resolve()\n+\n+        if not abs_path.exists() or not abs_path.is_relative_to(repo_root):\n+            return web.Response(text=\"404 Not Found\", status=404)\n+\n+        if abs_path.is_dir():\n+            return web.Response(text=self.render_directory(abs_path, username, repo_name, safe_rel_path), content_type='text/html')\n+        else:\n+            return web.Response(text=self.render_file(abs_path), content_type='text/html')\n+\n+    def render_directory(self, abs_path, username, repo_name, safe_rel_path):\n+        entries = sorted(abs_path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))\n+        items = []\n+\n+        if safe_rel_path:\n+            parent_path = Path(safe_rel_path).parent\n+            parent_link = f\"/repository/{username}/{repo_name}/{parent_path}\".rstrip('/')\n+            items.append(f'<li><a href=\"{parent_link}\">\u2b05\ufe0f ..</a></li>')\n+\n+        for entry in entries:\n+            link_path = urllib.parse.quote(str(Path(safe_rel_path) / entry.name))\n+            link = f\"/repository/{username}/{repo_name}/{link_path}\".rstrip('/')\n+            display = entry.name + ('/' if entry.is_dir() else '')\n+            size = '' if entry.is_dir() else humanize.naturalsize(entry.stat().st_size)\n+            icon = self.get_icon(entry)\n+            items.append(f'<li>{icon} <a href=\"{link}\">{display}</a> {size}</li>')\n+\n+        html = f\"\"\"\n+        <html>\n+        <head><title>\ud83d\udcc1 {repo_name}/{safe_rel_path}</title></head>\n+        <body>\n+        <h2>\ud83d\udcc1 {username}/{repo_name}/{safe_rel_path}</h2>\n+        <ul>\n+            {''.join(items)}\n+        </ul>\n+        </body>\n+        </html>\n+        \"\"\"\n+        return html\n+\n+    def render_file(self, abs_path):\n+        try:\n+            with open(abs_path, 'r', encoding='utf-8', errors='ignore') as f:\n+                content = f.read()\n+            return f\"<pre>{content}</pre>\"\n+        except Exception as e:\n+            return f\"<h1>Error</h1><pre>{e}</pre>\"\n+\n+    def get_icon(self, file):\n+        if file.is_dir(): return \"\ud83d\udcc1\"\n+        mime = mimetypes.guess_type(file.name)[0] or ''\n+        if mime.startswith(\"image\"): return \"\ud83d\uddbc\ufe0f\"\n+        if mime.startswith(\"text\"): return \"\ud83d\udcc4\"\n+        if mime.startswith(\"audio\"): return \"\ud83c\udfb5\"\n+        if mime.startswith(\"video\"): return \"\ud83c\udfac\"\n+        if file.name.endswith(\".py\"): return \"\ud83d\udc0d\"\n+        return \"\ud83d\udce6\"\n+"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added DBService and RPCView db methods", "commit": "dd108c20044540c3801ac461c612392bed76ff89", "diff": "commit dd108c20044540c3801ac461c612392bed76ff89\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 9 17:37:53 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex be356dc..a81b9e7 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -13,7 +13,7 @@ from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n from snek.system.object import Object\n-\n+from snek.service.db import DBService\n \n @functools.cache\n def get_services(app):\n@@ -31,6 +31,7 @@ def get_services(app):\n             \"drive_item\": DriveItemService(app=app),\n             \"user_property\": UserPropertyService(app=app),\n             \"repository\": RepositoryService(app=app),\n+            \"db\": DBService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/db.py b/src/snek/service/db.py\nnew file mode 100644\nindex 0000000..fe9fb8a\n--- /dev/null\n+++ b/src/snek/service/db.py\n@@ -0,0 +1,71 @@\n+from snek.system.service import BaseService\n+import dataset \n+import uuid \n+\n+from datetime import datetime \n+\n+class DBService(BaseService):\n+    \n+    async def get_db(self, user_uid):\n+        \n+        home_folder = await self.app.services.user.get_home_folder(user_uid)\n+        home_folder.mkdir(parents=True, exist_ok=True)\n+        db_path = home_folder.joinpath(\"snek/user.db\")\n+        db_path.parent.mkdir(parents=True, exist_ok=True)\n+    \n+    async def insert(self, user_uid, table_name, values):\n+        db = await self.get_db(user_uid)\n+        return db[table_name].insert(values)\n+        \n+\n+    async def update(self, user_uid, table_name, values, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        if not values:\n+            return False\n+        return db[table_name].update(values, filters)\n+\n+    async def upsert(self, user_uid, table_name, values, keys):\n+        db = await self.get_db(user_uid)\n+        return db[table_name].upsert(values, keys)\n+\n+    async def find(self, user_uid, table_name, kwargs):\n+        db = await self.get_db(user_uid)\n+        kwargs['_limit'] = kwargs.get('_limit', 30)\n+        return [dict(row) for row in db[table_name].find(**kwargs)]\n+\n+    async def get(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        try:\n+            return dict(db[table_name].find_one(**filters))\n+        except ValueError:\n+            return None\n+\n+\n+    async def delete(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return db[table_name].delete(**filters)\n+\n+    async def query(self, sql,values):\n+        db = await self.app.db\n+        return [dict(row) for row in db.query(sql, values or {})]\n+\n+    async def exists(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return bool(db[table_name].find_one(**filters))\n+\n+    \n+\n+    async def count(self, user_uid, table_name, filters):\n+        db = await self.get_db(user_uid)\n+        if not filters:\n+            filters = {}\n+        return db[table_name].count(**filters)\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 3161f49..4592f21 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -26,6 +26,31 @@ class RPCView(BaseView):\n             self.services = self.app.services\n             self.ws = ws\n \n+        async def db_insert(self, table_name, record):\n+            self._require_login()\n+\n+            return await self.services.db.insert(self.user_uid, table_name, record)\n+        async def db_update(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.update(self.user_uid, table_name, record)\n+        async def db_delete(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.delete(self.user_uid, table_name, record)\n+        async def db_get(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.get(self.user_uid, table_name, record)\n+        async def db_find(self, table_name, record):\n+            self._require_login()\n+            return await self.services.db.find(self.user_uid, table_name, record)\n+        async def db_upsert(self, table_name, record,keys):\n+            self._require_login()\n+            return await self.services.db.upsert(self.user_uid, table_name, record,keys)\n+\n+        async def db_query(self, table_name, args):\n+            self._require_login()\n+            return await self.services.db.query(self.user_uid, table_name, sql, args)\n+\n+\n         @property\n         def user_uid(self):\n             return self.view.session.get(\"uid\")"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added Drive API and HTML views for file management", "commit": "f0591d493955d9c126e7dee6d1a06917c48bbbd2", "diff": "commit f0591d493955d9c126e7dee6d1a06917c48bbbd2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 15:03:50 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 3e4ad5e..362d519 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -33,6 +33,7 @@ from snek.view.about import AboutHTMLView, AboutMDView\n from snek.view.avatar import AvatarView\n from snek.view.docs import DocsHTMLView, DocsMDView\n from snek.view.drive import DriveView\n+from snek.view.drive import DriveApiView\n from snek.view.index import IndexView\n from snek.view.login import LoginView\n from snek.view.logout import LogoutView\n@@ -178,7 +179,8 @@ class Application(BaseApplication):\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\n         self.router.add_view(\"/terminal.html\", TerminalView)\n-        self.router.add_view(\"/drive.json\", DriveView)\n+        self.router.add_view(\"/drive.json\", DriveApiView)\n+        self.router.add_view(\"/drive.html\", DriveView)\n         self.router.add_view(\"/drive/{drive}.json\", DriveView)\n         self.router.add_view(\"/stats.json\", StatsView)\n         self.router.add_view(\"/user/{user}.html\", UserView)\ndiff --git a/src/snek/static/file-manager.js b/src/snek/static/file-manager.js\nindex 55dbac6..b0d9765 100644\n--- a/src/snek/static/file-manager.js\n+++ b/src/snek/static/file-manager.js\n@@ -9,6 +9,7 @@ class FileBrowser extends HTMLElement {\n   }\n \n   connectedCallback() {\n+      this.path = this.getAttribute(\"path\") || \"\";\n     this.renderShell();\n     this.load();\n   }\n@@ -19,11 +20,11 @@ class FileBrowser extends HTMLElement {\n       <style>\n         :host { display:block; font-family: system-ui, sans-serif; box-sizing: border-box; }\n         nav    { display:flex; flex-wrap:wrap; gap:.5rem; margin:.5rem 0; align-items:center; }\n         .crumb  { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n         .grid   { display:grid; grid-template-columns:repeat(auto-fill,minmax(120px,1fr)); gap:1rem; }\n         .tile:hover { box-shadow:0 2px 8px rgba(0,0,0,.1); }\n         img.thumb { width:100%; height:90px; object-fit:cover; border-radius:6px; }\n         .icon { font-size:48px; line-height:90px; }\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 6b74792..a373c2d 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -35,6 +35,7 @@\n     <div class=\"logo no-select\">{% block header_text %}{% endblock %}</div>\n     <nav class=\"no-select\" style=\"overflow:hidden;scroll-behavior:smooth\">\n       <a class=\"no-select\" href=\"/web.html\">\ud83c\udfe0</a>\n+      <a class=\"no-select\" href=\"/drive.html\">\ud83d\udcc2</a>\n       <a class=\"no-select\" href=\"/search-user.html\">\ud83d\udd0d</a>\n       <a class=\"no-select\" href=\"/threads.html\">\ud83d\udc65</a>\ndiff --git a/src/snek/templates/drive.html b/src/snek/templates/drive.html\nnew file mode 100644\nindex 0000000..4a80d60\n--- /dev/null\n+++ b/src/snek/templates/drive.html\n@@ -0,0 +1,9 @@\n+{% extends \"app.html\" %}\n+\n+{% block header_text %}Drive{% endblock %}\n+\n+{% block main %}\n+<div class=\"container\">\n+<file-manager path=\"{{path}}\" style=\"flex: 1\"></file-manager>\n+</div>\n+{% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/create.html b/src/snek/templates/settings/repositories/create.html\nindex cfef080..ce3eacb 100644\n--- a/src/snek/templates/settings/repositories/create.html\n+++ b/src/snek/templates/settings/repositories/create.html\n@@ -3,43 +3,7 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-plus\"></i> Create Repository</h1>{% endblock %}\n \n {% block main %}\n-\n-<style>\n-.container {\n-    div,input,label,button{\n-        padding-bottom: 15px;\n-    }\n-}\n-    form {\n-      padding: 2rem;\n-      border-radius: 10px;\n-    }\n-    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n-    input[type=\"text\"] {\n-      padding: 0.5rem;\n-      font-size: 1rem;\n-    }\n-\n-\n-button, a.button {\n-      padding: 0.1rem 0.8rem; text-decoration: none; cursor: pointer;\n-      transition: background 0.2s;\n-      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n-    }\n-    .\n-    .cancel {\n-    }\n-    @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      form { padding: 1rem; }\n-    }\n-  </style>\n-</head>\n-<body>\n+{% include 'settings/repositories/form.html' %}\n   <div class=\"container\">\n     <form action=\"/settings/repositories/create.html\" method=\"post\">\n       <div>\n@@ -52,8 +16,9 @@ button, a.button {\n           <i class=\"fa-solid fa-lock\"></i> Private\n         </label>\n       </div>\n-      <button type=\"submit\"><i class=\"fa-solid fa-plus\"></i> Create</button>\n-      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i> Back</button> \n+      <button type=\"submit\"><i class=\"fa-solid fa-pen\"></i> Update</button>\n+      <button onclick=\"history.back()\" class=\"cancel\"><i class=\"fa-solid fa-arrow-left\"></i>Cancel</button>\n+\n     </form>\n   </div>\n   {% endblock %}\ndiff --git a/src/snek/templates/settings/repositories/delete.html b/src/snek/templates/settings/repositories/delete.html\nindex 5ba6c5b..d06003b 100644\n--- a/src/snek/templates/settings/repositories/delete.html\n+++ b/src/snek/templates/settings/repositories/delete.html\n@@ -3,33 +3,8 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-trash-can\"></i> Delete Repository</h1>{% endblock %}\n \n {% block main %}\n-  <style>\n-    .repo-name {\n-      font-weight: bold;\n-      font-size: 1.2rem;\n-      margin: 1rem 0;\n-    }\n-    .actions {\n-      display: flex; gap: 1rem; justify-content: left; margin-top: 1.5rem;\n-    }\n-    button {\n-      border: none; border-radius: 5px; padding: 0.6rem 1.2rem;\n-      font-size: 1rem; cursor: pointer;\n-      display: flex; align-items: center; gap: 0.5rem; text-decoration: none; justify-content: center;\n-      transition: background 0.2s;\n-    }\n-    .cancel {\n-    }\n-    @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      .confirm-box { padding: 1rem; }\n-    }\n-  </style>\n-  <div class=\"container\">\n+    {% include \"settings/repositories/form.html\" %}  \n+<div class=\"container\">\n       <p>Are you sure you want to <strong>delete</strong> the following repository?</p>\n       <div class=\"repo-name\"><i class=\"fa-solid fa-book\"></i> {{ repository.name }}</div>\n       <form method=\"post\" style=\"margin-top:1.5rem;\">\ndiff --git a/src/snek/templates/settings/repositories/form.html b/src/snek/templates/settings/repositories/form.html\nnew file mode 100644\nindex 0000000..8785cc6\n--- /dev/null\n+++ b/src/snek/templates/settings/repositories/form.html\n@@ -0,0 +1,28 @@\n+  <style>\n+    form {\n+      padding: 2rem;\n+      border-radius: 10px;\n+      div {\n+        padding: 10px;\n+        padding-bottom: 15px\n+      }\n+    }\n+    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n+    button {\n+      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n+      cursor: pointer;\n+      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n+    }\n+    .cancel {\n+    }\n+      @media (max-width: 600px) {\n+      .container { max-width: 98vw; }\n+      form { padding: 1rem; }\n+      }\n+\n+    </style>\n+\n+\ndiff --git a/src/snek/templates/settings/repositories/update.html b/src/snek/templates/settings/repositories/update.html\nindex 5168c92..93c9f72 100644\n--- a/src/snek/templates/settings/repositories/update.html\n+++ b/src/snek/templates/settings/repositories/update.html\n@@ -3,28 +3,8 @@\n {% block header_text %}<h1><i class=\"fa-solid fa-pen\"></i> Update Repository</h1>{% endblock %}\n \n {% block main %}\n-  <style>\n-    form {\n-      padding: 2rem;\n-      border-radius: 10px;\n-    }\n-    label { font-weight: bold; display: flex; align-items: center; gap: 0.5rem;}\n-    button {\n-      border: none; border-radius: 5px; padding: 0.6rem 1rem;\n-      cursor: pointer;\n-      font-size: 1rem; display: inline-flex; align-items: center; gap: 0.4rem;\n-    }\n-    .cancel {\n-    }\n-      @media (max-width: 600px) {\n-      .container { max-width: 98vw; }\n-      form { padding: 1rem; }\n-    }\n-  </style>\n-  <div class=\"container\">\n+{% include \"settings/repositories/form.html\" %}\n+    <div class=\"container\">\n     <form method=\"post\">\n       <!-- Assume hidden id for backend use -->\n       <input type=\"hidden\" name=\"id\" value=\"{{ repository.id }}\">\ndiff --git a/src/snek/view/drive.py b/src/snek/view/drive.py\nindex e3c3343..d6de50f 100644\n--- a/src/snek/view/drive.py\n+++ b/src/snek/view/drive.py\n@@ -11,47 +11,42 @@ from datetime import datetime\n \n \n \n-\"\"\"Run with:  python server.py  (Python\u00a0\u2265\u00a03.9)\n-\"\"\"\n from aiohttp import web\n from pathlib import Path\n import mimetypes, urllib.parse\n \n-BASE_DIR   = Path(__file__).parent.resolve()\n-ROOT_DIR.mkdir(exist_ok=True)\n-ASSETS_DIR.mkdir(exist_ok=True)\n+class DriveView(BaseView):\n \n+    async def get(self):\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+        rel_path = self.request.match_info.get(\"rel_path\", \"\")\n+        if rel_path:\n+            target = target.joinpath(rel_path)\n \n-def safe_resolve_path(rel: str) -> Path:\n-    \"\"\"Return *absolute* path inside ROOT_DIR or raise FileNotFoundError.\"\"\"\n-    target = (ROOT_DIR / rel.lstrip(\"/\")).resolve()\n-    if target == ROOT_DIR or ROOT_DIR in target.parents:\n-        return target\n-    raise FileNotFoundError(\"Unsafe path\")\n+        if not target.exists():\n+            return web.HTTPNotFound(reason=\"Path not found\")\n \n+        if target.is_dir():\n+            return await self.render_template(\"drive.html\",{\"path\": rel_path})\n+        if target.is_file():\n+            return web.FileResponse(target)\n \n-class DriveView(BaseView):\n+\n+class DriveApiView(BaseView):\n     async def get(self):\n+        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n         rel = self.request.query.get(\"path\", \"\")\n         offset = int(self.request.query.get(\"offset\", 0))\n         limit  = int(self.request.query.get(\"limit\", 20))\n-        target = await self.services.user.get_home_folder(self.session.get(\"uid\"))\n+\n         if rel:\n-            target.joinpath(rel)\n+            target = target.joinpath(rel)\n \n         if not target.exists():\n             return web.json_response({\"error\": \"Not found\"}, status=404)\n \n         if target.is_dir():\n             entries = []\n             for p in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):\n                 item_path = (Path(rel) / p.name).as_posix()\n                 mime = mimetypes.guess_type(p.name)[0] if p.is_file() else \"inode/directory\"\n@@ -73,10 +68,6 @@ class DriveView(BaseView):\n                 \"pagination\": {\"offset\": offset, \"limit\": limit, \"total\": total}\n             })\n         \n-        with open(target, \"rb\") as f:\n-            content = f.read()\n-            return web.Response(body=content, content_type=mimetypes.guess_type(target.name)[0])\n         url = self.request.url.with_path(f\"/drive/{urllib.parse.quote(rel)}\")\n         return web.json_response({\n             \"name\": target.name,"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Implemented typing indicator and glow effect for active users", "commit": "3412aa0bf0c0bb138c234f88fad55cb69267df79", "diff": "commit 3412aa0bf0c0bb138c234f88fad55cb69267df79\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 15:08:28 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/app.js b/src/snek/static/app.js\nindex 0517ff9..2603893 100644\n--- a/src/snek/static/app.js\n+++ b/src/snek/static/app.js\n@@ -151,7 +151,13 @@ export class App extends EventHandler {\n     ws = null;\n     rpc = null;\n     audio = null;\n-    user = {};\n+    user = {}; \n+    typeLock = null;\n+    typeListener = null\n+    typeEventChannelUid = null\n+    async set_typing(channel_uid){\n+    \tthis.typeEventChannel_uid = channel_uid\n+    }\n \n     async ping(...args) {\n         if (this.is_pinging) return false\n@@ -173,16 +179,25 @@ export class App extends EventHandler {\n         this.ping_interval = setInterval(() => {\n             this.ping(\"active\")\n         }, 15000)\n-\n+\tthis.typeEventChannelUid = null\n+\tthis.typeListener = setInterval(()=>{\n+\t\tif(this.typeEventChannelUid){\n+\t\t\tthis.rpc.set_typing(this.typeEventChannelUid)\n+\t\t\tthis.typeEventChannelUid = null\n+\t\t}\n+\t})\n \n         const me = this\n         this.ws.addEventListener(\"connected\", (data) => {\n             this.ping(\"online\")\n         })\n+\t\n         this.ws.addEventListener(\"channel-message\", (data) => {\n             me.emit(\"channel-message\", data);\n         });\n-\n+\tthis.ws.addEventListener(\"event\",(data)=>{\n+\t\tconsole.info(\"aaaa\")\t\n+\t})\n         this.rpc.getUser(null).then(user => {\n             me.user = user;\n         });\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 27153ee..90f20b4 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -366,6 +366,24 @@ a {\n }\n \n+@keyframes glow {\n+    0% {\n+    }\n+    50% {\n+    }\n+    100% {\n+    }\n+}\n+\n+.glow {\n+    animation: glow 1s;\n+}\n+\n+\n+\n @media only screen and (max-width: 768px) {\n     \n     header{\ndiff --git a/src/snek/static/socket.js b/src/snek/static/socket.js\nindex 83f6cac..78363be 100644\n--- a/src/snek/static/socket.js\n+++ b/src/snek/static/socket.js\n@@ -81,8 +81,13 @@ export class Socket extends EventHandler {\n         }\n         if (data.channel_uid) {\n             this.emit(data.channel_uid, data.data);\n+\t    if(!data['event'])\n             this.emit(\"channel-message\", data);\n         }\n+\tthis.emit(\"data\", data.data)\n+\tif(data['event']){\n+\t    this.emit(data.event, data)\n+\t}\n     }\n \n     disconnect() {\n@@ -134,4 +139,4 @@ export class Socket extends EventHandler {\n             me.sendJson(call);\n         });\n     }\n-}\n\\ No newline at end of file\n+}\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 3b25001..5a5cc2c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -24,7 +24,7 @@\n \n <script type=\"module\">\n     import { app } from \"/app.js\";\n-\n+    import { Schedule } from \"/schedule.js\";\n     const channelUid = \"{{ channel.uid.value }}\";\n \n     function getInputField(){\n@@ -40,7 +40,9 @@\n                     app.rpc.sendMessage(channelUid, message);\n                     e.target.value = '';\n                 }\n-            }\n+\t    }else{\n+\t\tapp.rpc.set_typing(channelUid)\n+\t    }\n         });\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n             getInputField().focus();\n@@ -74,6 +76,30 @@\n             }\n         });\n \n+\t function triggerGlow(uid) {\n+\t \tdocument.querySelectorAll(\".avatar\").forEach((el)=>{\n+\t\t   const div = el.closest('a');\n+\t\t   if(el.href.indexOf(uid)!=-1){\n+\t\t\tel.classList.add('glow')\n+\t\t    \tlet originalColor = el.style.backgroundColor \n+\t\t\tsetTimeout(()=>{\n+\t\t\t\tel.classList.remove('glow')\n+\t\t\t},1200)\n+\t\t    }\n+\n+\t      })\n+ \t\n+    \t}\n+\tapp.ws.addEventListener(\"set_typing\",(data)=>{\n+\t\ttriggerGlow(data.data.user_uid)\t\n+\n+\t})\n+\t\t\n+\n         const chatInput = document.querySelector(\".chat-area\")\n         chatInput.addEventListener(\"drop\", async (e) => {\n             e.preventDefault();\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 4592f21..645ccbc 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -33,6 +33,21 @@ class RPCView(BaseView):\n         async def db_update(self, table_name, record):\n             self._require_login()\n             return await self.services.db.update(self.user_uid, table_name, record)\n+        async def set_typing(self,channel_uid):\n+            self._require_login()\n+            user = await self.services.user.get(self.user_uid)\n+            return await self.services.socket.broadcast(channel_uid, {\n+                \"channel_uid\": \"293ecf12-08c9-494b-b423-48ba1a2d12c2\",\n+                \"event\": \"set_typing\",\n+                \"data\": {\n+                    \"event\":\"set_typing\",\n+                    \"user_uid\": user['uid'],\n+                    \"username\": user[\"username\"],\n+                    \"nick\": user[\"nick\"],\n+                    \"channel_uid\": channel_uid\n+                }\n+            })\n+\n         async def db_delete(self, table_name, record):\n             self._require_login()\n             return await self.services.db.delete(self.user_uid, table_name, record)"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added channel attachment functionality with file uploads and views", "commit": "9133b7c3ce6457fa6c218b540828c752b4ba5c72", "diff": "commit 9133b7c3ce6457fa6c218b540828c752b4ba5c72\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 20:38:32 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 362d519..0a6b018 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -53,6 +53,7 @@ from snek.view.terminal import TerminalSocketView, TerminalView\n from snek.view.upload import UploadView\n from snek.view.user import UserView\n from snek.view.web import WebView\n+from snek.view.channel import ChannelAttachmentView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n \n@@ -175,6 +176,8 @@ class Application(BaseApplication):\n         self.router.add_get(\"/http-get\", self.handle_http_get)\n         self.router.add_get(\"/http-photo\", self.handle_http_photo)\n         self.router.add_get(\"/rpc.ws\", RPCView)\n+        self.router.add_view(\"/channel/{channel_uid}/attachment.bin\",ChannelAttachmentView)\n+        self.router.add_view(\"/channel/attachment/{relative_url:.*}\",ChannelAttachmentView)\n         self.router.add_view(\"/channel/{channel}.html\", WebView)\n         self.router.add_view(\"/threads.html\", ThreadsView)\n         self.router.add_view(\"/terminal.ws\", TerminalSocketView)\ndiff --git a/src/snek/mapper/__init__.py b/src/snek/mapper/__init__.py\nindex ab7904f..5428f10 100644\n--- a/src/snek/mapper/__init__.py\n+++ b/src/snek/mapper/__init__.py\n@@ -9,6 +9,7 @@ from snek.mapper.notification import NotificationMapper\n from snek.mapper.user import UserMapper\n from snek.mapper.user_property import UserPropertyMapper\n from snek.mapper.repository import RepositoryMapper\n+from snek.mapper.channel_attachment import ChannelAttachmentMapper\n from snek.system.object import Object\n \n \n@@ -25,6 +26,7 @@ def get_mappers(app=None):\n             \"drive\": DriveMapper(app=app),\n             \"user_property\": UserPropertyMapper(app=app),\n             \"repository\": RepositoryMapper(app=app),\n+            \"channel_attachment\": ChannelAttachmentMapper(app=app),\n         }\n     )\n \ndiff --git a/src/snek/mapper/channel_attachment.py b/src/snek/mapper/channel_attachment.py\nnew file mode 100644\nindex 0000000..0d6e404\n--- /dev/null\n+++ b/src/snek/mapper/channel_attachment.py\n@@ -0,0 +1,7 @@\n+from snek.model.channel_attachment import ChannelAttachmentModel\n+from snek.system.mapper import BaseMapper\n+\n+\n+class ChannelAttachmentMapper(BaseMapper):\n+    table_name = \"channel_attachment\"\n+    model_class = ChannelAttachmentModel\ndiff --git a/src/snek/model/__init__.py b/src/snek/model/__init__.py\nindex 6399c89..17832c6 100644\n--- a/src/snek/model/__init__.py\n+++ b/src/snek/model/__init__.py\n@@ -11,6 +11,7 @@ from snek.model.notification import NotificationModel\n from snek.model.user import UserModel\n from snek.model.user_property import UserPropertyModel\n from snek.model.repository import RepositoryModel\n+from snek.model.channel_attachment import ChannelAttachmentModel\n from snek.system.object import Object\n \n \n@@ -27,6 +28,7 @@ def get_models():\n             \"notification\": NotificationModel,\n             \"user_property\": UserPropertyModel,\n             \"repository\": RepositoryModel,\n+            \"channel_attachment\": ChannelAttachmentModel,\n         }\n     )\n \ndiff --git a/src/snek/model/channel_attachment.py b/src/snek/model/channel_attachment.py\nnew file mode 100644\nindex 0000000..9add1b8\n--- /dev/null\n+++ b/src/snek/model/channel_attachment.py\n@@ -0,0 +1,16 @@\n+from snek.system.model import BaseModel\n+from snek.system.model import BaseModel, ModelField\n+\n+\n+class ChannelAttachmentModel(BaseModel):\n+\n+    name = ModelField(name=\"name\", required=True, kind=str)\n+    channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n+    path = ModelField(name=\"path\", required=True, kind=str)\n+    size = ModelField(name=\"size\", required=False, kind=int)\n+    user_uid = ModelField(name=\"user_uid\", required=True, kind=str)\n+    mime_type = ModelField(name=\"type\", required=True, kind=str)\n+    relative_url = ModelField(name=\"relative_url\", required=True, kind=str)\n+    resource_type = ModelField(name=\"resource_type\", required=True, kind=str,value=\"file\")\n+\n+\ndiff --git a/src/snek/service/__init__.py b/src/snek/service/__init__.py\nindex a81b9e7..dae9e09 100644\n--- a/src/snek/service/__init__.py\n+++ b/src/snek/service/__init__.py\n@@ -12,6 +12,7 @@ from snek.service.user import UserService\n from snek.service.user_property import UserPropertyService\n from snek.service.util import UtilService\n from snek.service.repository import RepositoryService\n+from snek.service.channel_attachment import ChannelAttachmentService\n from snek.system.object import Object\n from snek.service.db import DBService\n \n@@ -32,6 +33,7 @@ def get_services(app):\n             \"user_property\": UserPropertyService(app=app),\n             \"repository\": RepositoryService(app=app),\n             \"db\": DBService(app=app),\n+            \"channel_attachment\": ChannelAttachmentService(app=app),\n         }\n     )\n \ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex b90e66f..f2288cb 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -3,10 +3,19 @@ from datetime import datetime\n from snek.system.model import now\n from snek.system.service import BaseService\n \n+import pathlib \n \n class ChannelService(BaseService):\n     mapper_name = \"channel\"\n \n+    async def get_attachment_folder(self, channel_uid,ensure=False):\n+        path = pathlib.Path(f\"./drive/{channel_uid}/attachments\")\n+        if ensure:\n+            path.mkdir(\n+                parents=True, exist_ok=True\n+            )\n+        return path\n+\n     async def get(self, uid=None, **kwargs):\n         if uid:\n             kwargs[\"uid\"] = uid\ndiff --git a/src/snek/service/channel_attachment.py b/src/snek/service/channel_attachment.py\nnew file mode 100644\nindex 0000000..d225a7b\n--- /dev/null\n+++ b/src/snek/service/channel_attachment.py\n@@ -0,0 +1,25 @@\n+from snek.system.service import BaseService\n+import urllib.parse \n+import pathlib \n+import mimetypes\n+import uuid\n+\n+class ChannelAttachmentService(BaseService):\n+    mapper_name=\"channel_attachment\"\n+\n+    async def create_file(self, channel_uid, user_uid, name):\n+        attachment = await self.new()\n+        attachment[\"channel_uid\"] = channel_uid\n+        attachment['user_uid'] = user_uid\n+        attachment[\"name\"] = name\n+        attachment[\"mime_type\"] = mimetypes.guess_type(name)[0]\n+        attachment['resource_type'] = \"file\"\n+        real_file_name = f\"{attachment['uid']}-{name}\"\n+        attachment[\"relative_url\"] = urllib.parse.quote(f\"{attachment['uid']}/{name}\")  \n+        attachment_folder = await self.services.channel.get_attachment_folder(channel_uid)\n+        attachment_path = attachment_folder.joinpath(real_file_name)\n+        attachment[\"path\"] = str(attachment_path)\n+        if await self.save(attachment):\n+            return attachment\n+        raise Exception(f\"Failed to create channel attachment: {attachment.errors}.\")\n+\ndiff --git a/src/snek/static/upload-button.js b/src/snek/static/upload-button.js\nindex 06563c9..4fc2994 100644\n--- a/src/snek/static/upload-button.js\n+++ b/src/snek/static/upload-button.js\n@@ -21,13 +21,13 @@ class UploadButtonElement extends HTMLElement {\n \n         const files = fileInput.files;\n         const formData = new FormData();\n-        formData.append('channel_uid', this.channelUid);\n         for (let i = 0; i < files.length; i++) {\n             formData.append('files[]', files[i]);\n         }\n-\n         const request = new XMLHttpRequest();\n-        request.open('POST', '/drive.bin', true);\n+\n+        request.responseType = 'json';\n+        request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true);\n \n         request.upload.onprogress = function (event) {\n             if (event.lengthComputable) {\n@@ -35,9 +35,10 @@ class UploadButtonElement extends HTMLElement {\n                 uploadButton.innerText = `${Math.round(percentComplete)}%`;\n             }\n         };\n-\n+        const me = this\n         request.onload = function () {\n             if (request.status === 200) {\n+                me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response }));\n                 uploadButton.innerHTML = '\ud83d\udce4';\n             } else {\n                 alert('Upload failed');\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 5a5cc2c..0810587 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,6 +47,11 @@\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n             getInputField().focus();\n         })\n+        document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n+            e.detail.files.forEach((file)=>{\n+                app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`)\n+            })\n+        })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nnew file mode 100644\nindex 0000000..93ad412\n--- /dev/null\n+++ b/src/snek/view/channel.py\n@@ -0,0 +1,53 @@\n+from snek.system.view import BaseView\n+import aiofiles \n+from aiohttp import web\n+import pathlib \n+\n+class ChannelAttachmentView(BaseView):\n+    \n+    async def get(self):\n+        relative_path = self.request.match_info.get(\"relative_url\")\n+        channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path)\n+        response = web.FileResponse(channel_attachment[\"path\"])\n+        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n+        response.headers[\"Content-Disposition\"] = (\n+            f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+        )\n+        return response\n+\n+    async def post(self):\n+\n+        channel_uid = self.request.match_info.get(\"channel_uid\")\n+        user_uid = self.request.session.get(\"uid\")\n+\n+        channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False)\n+        \n+        if not channel_member:\n+            return web.HTTPNotFound()\n+\n+        reader = await self.request.multipart()\n+        attachments = []\n+\n+        while field := await reader.next():\n+\n+            filename = field.filename\n+            if not filename:\n+                continue\n+\n+            attachment = await self.services.channel_attachment.create_file(\n+                channel_uid=channel_uid, name=filename,user_uid=user_uid\n+            )\n+            \n+            attachments.append(attachment)\n+            pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True)\n+            async with aiofiles.open(attachment['path'], \"wb\") as f:\n+                while chunk := await field.read_chunk():\n+                    await f.write(chunk)\n+\n+        return web.json_response(\n+            {\n+                \"message\": \"Files uploaded successfully\",\n+                \"files\": [attachment.record for attachment in attachments],\n+                \"channel_uid\": channel_uid,\n+            }\n+        )"}
{"repo": ".", "date": "2025-05-10", "line": "fix: Remove debug print statement in SocketService", "commit": "4d7566de9bb3f2c54954fe72d0332caecd133ffa", "diff": "commit 4d7566de9bb3f2c54954fe72d0332caecd133ffa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 20:40:56 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex a3654d2..72d9b72 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -59,7 +59,6 @@ class SocketService(BaseService):\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n-                print(user_uid, flush=True)\n                 await self.send_to_user(user_uid, message)\n         except Exception as ex:\n             print(ex, flush=True)"}
{"repo": ".", "date": "2025-05-10", "line": "refactor: Removed hardcoded repository and user configurations", "commit": "2c9004418555dfc2a4c826e5c30aa0d59f332df7", "diff": "commit 2c9004418555dfc2a4c826e5c30aa0d59f332df7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 10 21:44:58 2025 +0200\n\n    xxx\n\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex f8bfeb7..65e18ac 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -16,11 +16,6 @@ class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n         super().__init__(client_max_size=1024*1024*1024*5)\n-        self.REPO_DIR = \"drive/repositories/3177f85e-dbb3-4406-993e-3d3748fea545\"\n-        self.USERS = {\n-            'x': 'x',\n-            'bob': 'bobpass',\n-        }\n         self.add_routes([\n             web.post('/create/{repo_name}', self.create_repository),\n             web.delete('/delete/{repo_name}', self.delete_repository),\n@@ -28,7 +23,7 @@ class GitApplication(web.Application):\n             web.post('/push/{repo_name}', self.push_repository),\n             web.post('/pull/{repo_name}', self.pull_repository),\n             web.get('/status/{repo_name}', self.status_repository),\n-            web.get('/list', self.list_repositories),\n             web.get('/branches/{repo_name}', self.list_branches),\n             web.post('/branches/{repo_name}', self.create_branch),\n             web.get('/log/{repo_name}', self.commit_log),"}
{"repo": ".", "date": "2025-05-11", "line": "feat: Added SSH server functionality with user-specific home directories and authentication.", "commit": "01846bf23f7883007b99a2e100240bf3b35b30f2", "diff": "commit 01846bf23f7883007b99a2e100240bf3b35b30f2\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun May 11 07:52:22 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 0a6b018..706f74e 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -20,6 +20,7 @@ from aiohttp_session.cookie_storage import EncryptedCookieStorage\n from app.app import Application as BaseApplication\n from jinja2 import FileSystemLoader\n \n+from snek.sssh import start_ssh_server\n from snek.docs.app import Application as DocsApplication\n from snek.mapper import get_mappers\n from snek.service import get_services\n@@ -95,15 +96,22 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(LinkifyExtension)\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n-\n+        self.ssh_host = \"0.0.0.0\"\n+        self.ssh_port = 2042\n         self.setup_router()\n+        self.ssh_server = None \n         self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n \n+    async def start_ssh_server(self, app):\n+        app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)\n+        asyncio.create_task(app.ssh_server.wait_closed())\n+\n     async def prepare_asyncio(self, app):\n         app.executor = ThreadPoolExecutor(max_workers=200)\ndiff --git a/src/snek/service/user.py b/src/snek/service/user.py\nindex 76e6d1c..13f660f 100644\n--- a/src/snek/service/user.py\n+++ b/src/snek/service/user.py\n@@ -32,10 +32,17 @@ class UserService(BaseService):\n             user[\"color\"] = await self.services.util.random_light_hex_color()\n         return await super().save(user)\n \n+    def authenticate_sync(self,username,password):\n+        user = self.get_by_username_sync(username)\n+          \n+        if not user:\n+            return False\n+        if not security.verify_sync(password, user[\"password\"]):\n+            return False\n+        return True \n+\n     async def authenticate(self, username, password):\n-        print(username, password, flush=True)\n         success = await self.validate_login(username, password)\n-        print(success, flush=True)\n         if not success:\n             return None\n \n@@ -61,6 +68,20 @@ class UserService(BaseService):\n         if not path.exists():\n             return None\n         return path\n+    \n+    def get_by_username_sync(self, username):\n+        user = self.mapper.db[\"user\"].find_one(username=username, deleted_at=None)\n+        return dict(user)\n+\n+    def get_home_folder_by_username(self, username):\n+        user = self.get_by_username_sync(username)\n+        folder = pathlib.Path(f\"./drive/{user['uid']}\")\n+        if not folder.exists():\n+            try:\n+                folder.mkdir(parents=True, exist_ok=True)\n+            except:\n+                pass\n+        return folder\n \n     async def get_home_folder(self, user_uid):\n         folder = pathlib.Path(f\"./drive/{user_uid}\")\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nnew file mode 100644\nindex 0000000..0106a17\n--- /dev/null\n+++ b/src/snek/sssh.py\n@@ -0,0 +1,95 @@\n+import asyncio\n+import asyncssh\n+import logging\n+import os\n+from pathlib import Path\n+import sys \n+import pty \n+\n+global _app \n+\n+\n+def set_app(app):\n+    global _app\n+    _app = app \n+\n+def get_app():\n+    return _app\n+\n+logging.basicConfig(\n+    level=logging.DEBUG,\n+    format=\"%(asctime)s - %(levelname)s - %(message)s\",\n+    handlers=[\n+        logging.FileHandler(\"sftp_server.log\"),\n+        logging.StreamHandler()\n+    ]\n+)\n+logger = logging.getLogger(__name__)\n+\n+roots = {}\n+\n+class MySFTPServer(asyncssh.SFTPServer):\n+   \n+    def __init__(self, chan: asyncssh.SSHServerChannel):\n+        self.root = get_app().services.user.get_home_folder_by_username(\n+            chan.get_extra_info('username')\n+        )\n+        self.root.mkdir(exist_ok=True)\n+        self.root = str(self.root)\n+        super().__init__(chan, chroot=self.root)\n+\n+    def map_path(self, path):\n+\n+        mapped_path = Path(self.root).joinpath(path.lstrip(b\"/\").decode())\n+        print(mapped_path)\n+        logger.debug(f\"Mapping client path {path} to {mapped_path}\")\n+        return str(mapped_path).encode()\n+\n+class MySSHServer(asyncssh.SSHServer):\n+    def password_auth_supported(self):\n+        return True\n+\n+    def validate_password(self, username, password):\n+        \n+        logger.debug(f\"Validating credentials for user {username}\")\n+        return get_app().services.user.authenticate_sync(username,password)\n+\n+\n+async def start_ssh_server(app,host,port):\n+    set_app(app) \n+    logger.info(\"Starting SFTP server setup\")\n+    host_key_path = Path(\"drive\") / \".ssh\" / \"sftp_server_key\"\n+    host_key_path.parent.mkdir(exist_ok=True)\n+    try:\n+        if not host_key_path.exists():\n+            logger.info(f\"Generating new host key at {host_key_path}\")\n+            key = asyncssh.generate_private_key(\"ecdsa-sha2-nistp256\")\n+            key.write_private_key(host_key_path)\n+        else:\n+            logger.info(f\"Loading existing host key from {host_key_path}\")\n+            key = asyncssh.read_private_key(host_key_path)\n+    except Exception as e:\n+        logger.error(f\"Failed to generate or load host key: {e}\")\n+        raise\n+\n+    logger.info(\"Starting SFTP server on localhost:8022\")\n+    try:\n+        x = await asyncssh.listen(\n+            host=host,\n+            port=port,\n+            server_host_keys=[key],\n+            server_factory=MySSHServer,\n+            sftp_factory=MySFTPServer\n+        )\n+        return x\n+    except Exception as e:\n+        logger.error(f\"Failed to start SFTP server: {e}\")\n+        raise\n+\n+\ndiff --git a/src/snek/system/security.py b/src/snek/system/security.py\nindex 43b61fe..4ead284 100644\n--- a/src/snek/system/security.py\n+++ b/src/snek/system/security.py\n@@ -40,7 +40,7 @@ def uid(value: str = None, ns: str = DEFAULT_NS) -> str:\n     return str(uuid.uuid5(UIDNS(ns), value))\n \n \n-async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+def hash_sync(data: str, salt: str = DEFAULT_SALT) -> str:\n     \"\"\"Hash the given data with the specified salt using SHA-256.\n \n     Args:\n@@ -63,8 +63,10 @@ async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n     obj = hashlib.sha256(salted)\n     return obj.hexdigest()\n \n+async def hash(data: str, salt: str = DEFAULT_SALT) -> str:\n+    return hash_sync(data, salt)\n \n-async def verify(string: str, hashed: str) -> bool:\n+def verify_sync(string: str, hashed: str) -> bool:\n     \"\"\"Verify if the given string matches the hashed value.\n \n     Args:\n@@ -74,4 +76,7 @@ async def verify(string: str, hashed: str) -> bool:\n     Returns:\n         bool: True if the string matches the hashed value, False otherwise.\n     \"\"\"\n-    return await hash(string) == hashed\n+    return hash_sync(string) == hashed\n+\n+async def verify(string: str, hashed: str) -> bool:\n+    return verify_sync(string, hashed)"}
{"repo": ".", "date": "2025-05-11", "line": "fix: Updated SSH port to 2242", "commit": "c48b84bf3ab7cff5dee5670e23db3d771e14fc46", "diff": "commit c48b84bf3ab7cff5dee5670e23db3d771e14fc46\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sun May 11 07:52:58 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex 706f74e..a8ed41c 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -97,7 +97,7 @@ class Application(BaseApplication):\n         self.jinja2_env.add_extension(PythonExtension)\n         self.jinja2_env.add_extension(EmojiExtension)\n         self.ssh_host = \"0.0.0.0\"\n-        self.ssh_port = 2042\n+        self.ssh_port = 2242\n         self.setup_router()\n         self.ssh_server = None \n         self.executor = None"}
{"repo": ".", "date": "2025-05-12", "line": "feat: Add image conversion and resizing support in channel attachments", "commit": "f156a153de1b2f89b99cf0490eb18bf27a611fe1", "diff": "commit f156a153de1b2f89b99cf0490eb18bf27a611fe1\nAuthor: BordedDev <>\nDate:   Mon May 12 01:47:54 2025 +0200\n\n    Add image conversion and resizing support in channel attachments\n\ndiff --git a/pyproject.toml b/pyproject.toml\nindex cc84391..6cb0070 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -33,8 +33,10 @@ dependencies = [\n     \"PyJWT\",\n     \"multiavatar\",\n     \"gitpython\",\n-    \"uvloop\",\n-    \"humanize\"\n+    'uvloop; platform_system != \"Windows\"',\n+    \"humanize\",\n+    \"Pillow\",\n+    \"pillow-heif\",\n ]\n \n [tool.setuptools.packages.find]\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 35e56e3..0f06499 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,5 +1,4 @@\n import click\n-import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n@@ -14,7 +13,12 @@ def cli():\n @click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n def serve(port, host, db_path):\n-    asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    try:\n+        import uvloop\n+        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n+    except ImportError:\n+        print(\"uvloop not installed, using default event loop.\")\n+\n     web.run_app(\n     )\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nindex 0106a17..848b2f9 100644\n--- a/src/snek/sssh.py\n+++ b/src/snek/sssh.py\n@@ -1,10 +1,6 @@\n-import asyncio\n import asyncssh\n import logging\n-import os\n from pathlib import Path\n-import sys \n-import pty \n \n global _app \n \ndiff --git a/src/snek/system/markdown.py b/src/snek/system/markdown.py\nindex 82a222e..b708666 100644\n--- a/src/snek/system/markdown.py\n+++ b/src/snek/system/markdown.py\n@@ -4,6 +4,9 @@ from types import SimpleNamespace\n \n from app.cache import time_cache_async\n from mistune import HTMLRenderer, Markdown\n+from mistune.plugins.formatting import strikethrough\n+from mistune.plugins.spoiler import spoiler\n+from mistune.plugins.url import url\n from pygments import highlight\n from pygments.formatters import html\n from pygments.lexers import get_lexer_by_name\n@@ -14,6 +17,8 @@ class MarkdownRenderer(HTMLRenderer):\n     _allow_harmful_protocols = True\n \n     def __init__(self, app, template):\n+        super().__init__(False, True)\n+\n         self.template = template\n \n         self.app = app\n@@ -46,10 +51,18 @@ class MarkdownRenderer(HTMLRenderer):\n         markdown = Markdown(renderer=renderer)\n         return markdown(markdown_string)\n \n+\n \n def render_markdown_sync(app, markdown_string):\n     renderer = MarkdownRenderer(app, None)\n-    markdown = Markdown(renderer=renderer)\n+    markdown = Markdown(renderer=renderer, plugins=[url, strikethrough, spoiler])\n     return markdown(markdown_string)\n \n \ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d4b6819..93fd33c 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -1,6 +1,7 @@\n import re\n from types import SimpleNamespace\n \n+import mimetypes\n import emoji\n from bs4 import BeautifulSoup\n from jinja2 import TemplateSyntaxError, nodes\n@@ -105,21 +106,38 @@ def embed_youtube(text):\n def embed_image(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"a\"):\n-        for extension in [\n-            \".png\",\n-            \".jpg\",\n-            \".jpeg\",\n-            \".gif\",\n-            \".webp\",\n-            \".svg\",\n-            \".bmp\",\n-            \".tiff\",\n-            \".ico\",\n-            \".heif\",\n-        ]:\n-            if extension in element.attrs[\"href\"].lower():\n-                embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n-                element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+        file_mime = mimetypes.guess_type(element.attrs[\"href\"])[0]\n+\n+        if file_mime and file_mime.startswith(\"image/\") or any(\n+            ext in element.attrs[\"href\"].lower() for ext in [\n+                \".png\",\n+                \".jpg\",\n+                \".jpeg\",\n+                \".gif\",\n+                \".webp\",\n+                \".svg\",\n+                \".bmp\",\n+                \".tiff\",\n+                \".ico\",\n+                \".heif\",\n+                \".heic\",\n+            ]\n+        ):\n+            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+            element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n+    return str(soup)\n+\n+def enrich_image_rendering(text):\n+    soup = BeautifulSoup(text, \"html.parser\")\n+    for element in soup.find_all(\"img\"):\n+        if element.attrs[\"src\"].startswith(\"/\"   ):\n+            picture_template = f'''\n+                                <picture>\n+                                    <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n+                                    <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n+                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                </picture>'''\n+            element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)\n \n \n@@ -205,6 +223,8 @@ class LinkifyExtension(Extension):\n         result = embed_media(result)\n         result = embed_image(result)\n         result = embed_youtube(result)\n+\n+        result = enrich_image_rendering(result)\n         return result\n \n \ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 0810587..1046d2e 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -49,7 +49,7 @@\n         })\n         document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n             e.detail.files.forEach((file)=>{\n-                app.rpc.sendMessage(channelUid,`![${file.name}](/channel/attachment/${file.relative_url})`)\n+                app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)\n             })\n         })\n         textBox.addEventListener(\"paste\", async (e) => {\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 93ad412..86ed7a0 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -1,27 +1,100 @@\n+import asyncio\n+import mimetypes\n+\n+from PIL import Image\n+import pillow_heif.HeifImagePlugin\n+\n from snek.system.view import BaseView\n-import aiofiles \n+import aiofiles\n from aiohttp import web\n-import pathlib \n+import pathlib\n+\n \n class ChannelAttachmentView(BaseView):\n-    \n     async def get(self):\n         relative_path = self.request.match_info.get(\"relative_url\")\n-        channel_attachment = await self.services.channel_attachment.get(relative_url=relative_path)\n-        response = web.FileResponse(channel_attachment[\"path\"])\n-        response.headers[\"Cache-Control\"] = f\"public, max-age={1337*420}\"\n-        response.headers[\"Content-Disposition\"] = (\n-            f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+        channel_attachment = await self.services.channel_attachment.get(\n+            relative_url=relative_path\n         )\n-        return response\n \n-    async def post(self):\n+        current_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n+\n+        format = self.request.query.get(\"format\")\n+        width = self.request.query.get(\"width\")\n+        height = self.request.query.get(\"height\")\n+\n+        if any([format, width, height]) and current_format.startswith(\"image/\"):\n+            with Image.open(channel_attachment[\"path\"]) as image:\n+                response = web.StreamResponse(\n+                    status=200,\n+                    reason=\"OK\",\n+                    headers={\n+                        \"Cache-Control\": f\"public, max-age={1337 * 420}\",\n+                        \"Content-Type\": f\"image/{format}\" if format else current_format,\n+                        \"Content-Disposition\": f'attachment; filename=\"{channel_attachment[\"name\"]}\"',\n+                    },\n+                )\n+\n+                if width or height:\n+                    width = min(int(width), image.size[0]) if width else None\n+                    height = min(int(height), image.size[1]) if height else None\n+\n+                    if width and height:\n+                        smallest_ratio = max(\n+                            image.size[0] / int(width), image.size[1] / int(height)\n+                        )\n+                        image.thumbnail(\n+                            (\n+                                int(image.size[0] / smallest_ratio),\n+                                int(image.size[1] / smallest_ratio),\n+                            )\n+                        )\n+                    elif width:\n+                        image.thumbnail(\n+                            (\n+                                int(width),\n+                                int(image.size[1] * image.size[0] / int(width)),\n+                            )\n+                        )\n+                    elif height:\n+                        image.thumbnail(\n+                            (\n+                                int(image.size[0] * image.size[1] / int(height)),\n+                                int(height),\n+                            )\n+                        )\n+\n+                await response.prepare(self.request)\n+\n+                naughty_steal = response.write\n+                loop = asyncio.get_event_loop()\n \n+                def sync_writer(*args, **kwargs):\n+                    return loop.run_until_complete(naughty_steal(*args, **kwargs))\n+\n+                setattr(response, \"write\", sync_writer)\n+\n+                image.save(response, format=self.request.query[\"format\"])\n+\n+                setattr(response, \"write\", naughty_steal)\n+                return response\n+        else:\n+            response = web.FileResponse(channel_attachment[\"path\"])\n+            response.headers[\"Cache-Control\"] = f\"public, max-age={1337 * 420}\"\n+            response.headers[\"Content-Disposition\"] = (\n+                f'attachment; filename=\"{channel_attachment[\"name\"]}\"'\n+            )\n+            return response\n+\n+    async def post(self):\n         channel_uid = self.request.match_info.get(\"channel_uid\")\n         user_uid = self.request.session.get(\"uid\")\n \n-        channel_member = await self.services.channel_member.get(user_uid=user_uid, channel_uid=channel_uid,deleted_at=None,is_banned=False)\n-        \n+        channel_member = await self.services.channel_member.get(\n+            user_uid=user_uid, channel_uid=channel_uid, deleted_at=None, is_banned=False\n+        )\n+\n         if not channel_member:\n             return web.HTTPNotFound()\n \n@@ -29,18 +102,17 @@ class ChannelAttachmentView(BaseView):\n         attachments = []\n \n         while field := await reader.next():\n-\n             filename = field.filename\n             if not filename:\n                 continue\n \n             attachment = await self.services.channel_attachment.create_file(\n-                channel_uid=channel_uid, name=filename,user_uid=user_uid\n+                channel_uid=channel_uid, name=filename, user_uid=user_uid\n             )\n-            \n+\n             attachments.append(attachment)\n-            pathlib.Path(attachment['path']).parent.mkdir(parents=True, exist_ok=True)\n-            async with aiofiles.open(attachment['path'], \"wb\") as f:\n+            pathlib.Path(attachment[\"path\"]).parent.mkdir(parents=True, exist_ok=True)\n+            async with aiofiles.open(attachment[\"path\"], \"wb\") as f:\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added image conversion and resizing support for channel attachments", "commit": "ac2f68f93fd66c0d6ab3682525b2d9c94febff4d", "diff": "commit ac2f68f93fd66c0d6ab3682525b2d9c94febff4d\nMerge: c48b84b f156a15\nAuthor: retoor <retoor@noreply@molodetz.nl>\nDate:   Tue May 13 18:19:57 2025 +0200\n\n    \n    Reviewed-by: retoor <retoor@noreply@molodetz.nl>"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Improved Windows compatibility and added database initialization.", "commit": "a4bea9449526fc8f6b01c02d777db5c30186b830", "diff": "commit a4bea9449526fc8f6b01c02d777db5c30186b830\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 18:32:59 2025 +0200\n\n    Windows friendly solution.\n\ndiff --git a/Makefile b/Makefile\nindex 852efd4..9b60f5a 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -20,7 +20,6 @@ serve: run\n \n run:\n \t.venv/bin/snek serve\n \t\n install: ubuntu\n \tpython3.12 -m venv .venv \ndiff --git a/pyproject.toml b/pyproject.toml\nindex 6cb0070..00a4edb 100644\n--- a/pyproject.toml\n+++ b/pyproject.toml\n@@ -18,7 +18,8 @@ dependencies = [\n     \"lxml\",\n     \"IPython\",\n     \"shed\",\n     \"beautifulsoup4\",\n     \"gunicorn\",\n     \"imgkit\",\ndiff --git a/src/snek/__main__.py b/src/snek/__main__.py\nindex 0f06499..360bf5a 100644\n--- a/src/snek/__main__.py\n+++ b/src/snek/__main__.py\n@@ -1,24 +1,45 @@\n import click\n+import uvloop\n from aiohttp import web\n import asyncio\n from snek.app import Application\n from IPython import start_ipython\n+import sqlite3\n+import pathlib\n+import shutil \n \n @click.group()\n def cli():\n     pass\n \n+@cli.command()\n+@click.option('--db_path',default=\"snek.db\", help='Database to initialize if not exists.')\n+@click.option('--source',default=None, help='Database to initialize if not exists.')\n+def init(db_path,source):\n+    if source and pathlib.Path(source).exists():\n+        print(f\"Copying {source} to {db_path}\")\n+        shutil.copy2(source,db_path)\n+        print(\"Database initialized.\")\n+        return\n+ \n+    if pathlib.Path(db_path).exists():\n+        return\n+    print(f\"Initializing database at {db_path}\")\n+    db = sqlite3.connect(db_path)\n+    db.cursor().executescript(\n+        pathlib.Path(__file__).parent.joinpath(\"schema.sql\").read_text()\n+    )\n+    db.commit()\n+    db.close()\n+    print(\"Database initialized.\")\n+\n @cli.command()\n @click.option('--port', default=8081, show_default=True, help='Port to run the application on')\n @click.option('--host', default='0.0.0.0', show_default=True, help='Host to run the application on')\n @click.option('--db_path', default='snek.db', show_default=True, help='Database path for the application')\n def serve(port, host, db_path):\n-    try:\n-        import uvloop\n-        asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())\n-    except ImportError:\n-        print(\"uvloop not installed, using default event loop.\")\n-\n     web.run_app(\n     )\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex a8ed41c..bbf6539 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -3,9 +3,9 @@ import logging\n import pathlib\n import time\n import uuid\n-\n+from snek import snode \n from snek.view.threads import ThreadsView\n-\n+import json \n logging.basicConfig(level=logging.DEBUG)\n \n from concurrent.futures import ThreadPoolExecutor\n@@ -57,7 +57,6 @@ from snek.view.web import WebView\n from snek.view.channel import ChannelAttachmentView\n from snek.webdav import WebdavApplication\n from snek.sgit import GitApplication\n-\n SESSION_KEY = b\"c79a0c5fda4b424189c427d28c9f7c34\"\n \n \n@@ -99,18 +98,26 @@ class Application(BaseApplication):\n         self.ssh_host = \"0.0.0.0\"\n         self.ssh_port = 2242\n         self.setup_router()\n-        self.ssh_server = None \n+        self.ssh_server = None\n+        self.sync_service = None\n         self.executor = None\n         self.cache = Cache(self)\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n+        self.broadcast_service = None\n         self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_asyncio)\n         self.on_startup.append(self.prepare_database)\n+        \n+\n \n+    async def snode_sync(self, app):\n+        self.sync_service = asyncio.create_task(snode.sync_service(app))\n+    \n     async def start_ssh_server(self, app):\n         app.ssh_server = await start_ssh_server(app,app.ssh_host,app.ssh_port)\n-        asyncio.create_task(app.ssh_server.wait_closed())\n+        if app.ssh_server:\n+            asyncio.create_task(app.ssh_server.wait_closed())\n \n     async def prepare_asyncio(self, app):\ndiff --git a/src/snek/service/drive_item.py b/src/snek/service/drive_item.py\nindex ce747c1..7a0c59a 100644\n--- a/src/snek/service/drive_item.py\n+++ b/src/snek/service/drive_item.py\n@@ -16,4 +16,5 @@ class DriveItemService(BaseService):\n         if await self.save(model):\n             return model\n         errors = await model.errors\n+        print(\"XXXXXXXXXX\")\n         raise Exception(f\"Failed to create drive item: {errors}.\")\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex 72d9b72..eb40234 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,6 +1,7 @@\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n-\n+from datetime import datetime \n+import json \n \n class SocketService(BaseService):\n \n@@ -10,6 +11,7 @@ class SocketService(BaseService):\n             self.is_connected = True\n             self.user = user\n \n+\n         async def send_json(self, data):\n             if not self.is_connected:\n                 return False\n@@ -33,7 +35,8 @@ class SocketService(BaseService):\n         self.sockets = set()\n         self.users = {}\n         self.subscriptions = {}\n-\n+        self.last_update = str(datetime.now())\n+    \n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n@@ -54,7 +57,11 @@ class SocketService(BaseService):\n                 count += 1\n         return count\n \n+\n     async def broadcast(self, channel_uid, message):\n+        await self._broadcast(channel_uid, message)\n+\n+    async def _broadcast(self, channel_uid, message):\n         try:\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\ndiff --git a/src/snek/sgit.py b/src/snek/sgit.py\nindex 65e18ac..3cbdcf6 100644\n--- a/src/snek/sgit.py\n+++ b/src/snek/sgit.py\n@@ -1,7 +1,7 @@\n import os\n import aiohttp\n from aiohttp import web\n-import git\n+\n import shutil\n import json\n import tempfile\n@@ -14,6 +14,8 @@ logger = logging.getLogger('git_server')\n \n class GitApplication(web.Application):\n     def __init__(self, parent=None):\n         self.parent = parent\n         super().__init__(client_max_size=1024*1024*1024*5)\n         self.add_routes([\ndiff --git a/src/snek/sssh.py b/src/snek/sssh.py\nindex 848b2f9..6331efa 100644\n--- a/src/snek/sssh.py\n+++ b/src/snek/sssh.py\n@@ -4,7 +4,6 @@ from pathlib import Path\n \n global _app \n \n-\n def set_app(app):\n     global _app\n     _app = app \n@@ -12,19 +11,11 @@ def set_app(app):\n def get_app():\n     return _app\n \n-logging.basicConfig(\n-    level=logging.DEBUG,\n-    format=\"%(asctime)s - %(levelname)s - %(message)s\",\n-    handlers=[\n-        logging.FileHandler(\"sftp_server.log\"),\n-        logging.StreamHandler()\n-    ]\n-)\n logger = logging.getLogger(__name__)\n \n roots = {}\n \n-class MySFTPServer(asyncssh.SFTPServer):\n+class SFTPServer(asyncssh.SFTPServer):\n    \n     def __init__(self, chan: asyncssh.SSHServerChannel):\n         self.root = get_app().services.user.get_home_folder_by_username(\n@@ -35,29 +26,24 @@ class MySFTPServer(asyncssh.SFTPServer):\n         super().__init__(chan, chroot=self.root)\n \n     def map_path(self, path):\n-\n         mapped_path = Path(self.root).joinpath(path.lstrip(b\"/\").decode())\n-        print(mapped_path)\n         logger.debug(f\"Mapping client path {path} to {mapped_path}\")\n         return str(mapped_path).encode()\n \n-class MySSHServer(asyncssh.SSHServer):\n+class SSHServer(asyncssh.SSHServer):\n     def password_auth_supported(self):\n         return True\n \n     def validate_password(self, username, password):\n-        \n         logger.debug(f\"Validating credentials for user {username}\")\n-        return get_app().services.user.authenticate_sync(username,password)\n-\n+        result = get_app().services.user.authenticate_sync(username,password)\n+        logger.info(f\"Validating credentials for user {username}: {result}\")\n+        return result\n \n async def start_ssh_server(app,host,port):\n     set_app(app) \n     logger.info(\"Starting SFTP server setup\")\n+    \n     host_key_path = Path(\"drive\") / \".ssh\" / \"sftp_server_key\"\n     host_key_path.parent.mkdir(exist_ok=True)\n     try:\n@@ -72,20 +58,19 @@ async def start_ssh_server(app,host,port):\n         logger.error(f\"Failed to generate or load host key: {e}\")\n         raise\n \n-    logger.info(\"Starting SFTP server on localhost:8022\")\n+    logger.info(f\"Starting SFTP server on 127.0.0.1:{port}\")\n     try:\n         x = await asyncssh.listen(\n             host=host,\n             port=port,\n             server_host_keys=[key],\n-            server_factory=MySSHServer,\n-            sftp_factory=MySFTPServer\n+            server_factory=SSHServer,\n+            sftp_factory=SFTPServer\n         )\n         return x\n     except Exception as e:\n-        logger.error(f\"Failed to start SFTP server: {e}\")\n-        raise\n+        logger.warning(f\"Failed to start SFTP server. Already running.\")\n+        pass \n \n \ndiff --git a/src/snek/view/repository.py b/src/snek/view/repository.py\nindex 0c19142..60fa1bb 100644\n--- a/src/snek/view/repository.py\n+++ b/src/snek/view/repository.py\n@@ -6,7 +6,7 @@ import humanize\n from aiohttp import web\n from snek.system.view import BaseView\n import asyncio \n-from git import Repo\n+"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added WebSocket client and server for database synchronization.", "commit": "ba3152f553afcfae318811a413cdea6f5be9f413", "diff": "commit ba3152f553afcfae318811a413cdea6f5be9f413\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 18:30:31 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/research/serpentarium.py b/src/snek/research/serpentarium.py\nnew file mode 100644\nindex 0000000..c87b70b\n--- /dev/null\n+++ b/src/snek/research/serpentarium.py\n@@ -0,0 +1,312 @@\n+\n+import json\n+import asyncio\n+import aiohttp\n+from aiohttp import web\n+import dataset\n+import dataset.util\n+import traceback\n+import socket\n+import base64\n+import uuid \n+\n+class DatasetMethod:\n+    def __init__(self, dt, name):\n+        self.dt = dt\n+        self.name = name \n+\n+    def __call__(self, *args, **kwargs):\n+        return self.dt.ds.call(\n+            self.dt.name,\n+            self.name,\n+            *args,\n+            **kwargs\n+        )\n+\n+\n+class DatasetTable:\n+\n+    def __init__(self, ds, name):\n+        self.ds = ds \n+        self.name = name \n+\n+    def __getattr__(self, name):\n+        return DatasetMethod(self, name)\n+\n+class WebSocketClient:\n+    def __init__(self):\n+        self.buffer = b''\n+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n+        self.connect()     \n+\n+    def connect(self):\n+        self.socket.connect((\"127.0.0.1\", 3131))\n+        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n+        handshake = (\n+            f\"GET /db HTTP/1.1\\r\\n\"\n+            f\"Host: localhost:3131\\r\\n\"\n+            f\"Upgrade: websocket\\r\\n\"\n+            f\"Connection: Upgrade\\r\\n\"\n+            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n+            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n+        )\n+        self.socket.sendall(handshake.encode('utf-8'))\n+        response = self.read_until(b'\\r\\n\\r\\n')\n+        if b'101 Switching Protocols' not in response:\n+            raise Exception(\"Failed to connect to WebSocket\")\n+\n+    def write(self, message):\n+        message_bytes = message.encode('utf-8')\n+        length = len(message_bytes)\n+        if length <= 125:\n+            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n+        elif length >= 126 and length <= 65535:\n+            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n+        else:\n+            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n+        \n+\n+    def read_until(self, delimiter): \n+        while True:\n+            find_pos = self.buffer.find(delimiter)\n+            if find_pos != -1:\n+                data = self.buffer[:find_pos+4]\n+                self.buffer = self.buffer[find_pos+4:]\n+                return data \n+            \n+            chunk = self.socket.recv(1024)\n+            if not chunk:\n+                return None\n+            self.buffer += chunk\n+            \n+    def read_exactly(self, length):\n+        while len(self.buffer) < length:\n+            chunk = self.socket.recv(length - len(self.buffer))\n+            if not chunk:\n+                return None\n+            self.buffer += chunk \n+        response = self.buffer[: length]\n+        self.buffer = self.buffer[length:]\n+        return response\n+\n+    def read(self):\n+        frame = None \n+        frame = self.read_exactly(2)\n+        length = frame[1] & 127\n+        if length == 126:\n+            length = int.from_bytes(self.read_exactly(2), 'big')\n+        elif length == 127:\n+            length = int.from_bytes(self.read_exactly(8), 'big')\n+        message = self.read_exactly(length)\n+        return message\n+    \n+    def close(self):\n+        self.socket.close()\n+\n+\n+\n+\n+class WebSocketClient2:\n+    def __init__(self, uri):\n+        self.uri = uri\n+        self.loop = asyncio.get_event_loop()\n+        self.websocket = None\n+        self.receive_queue = asyncio.Queue()\n+\n+        if self.loop.is_running():\n+            self._connect_future = asyncio.run_coroutine_threadsafe(self._connect(), self.loop)\n+        else:\n+            self.loop.run_until_complete(self._connect())\n+\n+    async def _connect(self):\n+        self.websocket = await websockets.connect(self.uri)\n+        asyncio.create_task(self._receive_loop())\n+\n+    async def _receive_loop(self):\n+        try:\n+            async for message in self.websocket:\n+                await self.receive_queue.put(message)\n+        except Exception:\n+\n+    def send(self, message: str):\n+        if self.loop.is_running():\n+            asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)\n+        else:\n+            self.loop.run_until_complete(self.websocket.send(message))\n+\n+    def receive(self):\n+        future = asyncio.run_coroutine_threadsafe(self.receive_queue.get(), self.loop)\n+        return future.result()\n+\n+    def close(self):\n+        if self.websocket:\n+            if self.loop.is_running():\n+                asyncio.run_coroutine_threadsafe(self.websocket.close(), self.loop)\n+            else:\n+                self.loop.run_until_complete(self.websocket.close())\n+\n+\n+import websockets \n+\n+class DatasetWrapper(object):\n+\n+    def __init__(self):\n+        self.ws = WebSocketClient() \n+\n+    def begin(self):\n+        self.call(None, 'begin')\n+\n+    def commit(self):\n+        self.call(None, 'commit')\n+\n+    def __getitem__(self, name):\n+        return DatasetTable(self, name)\n+\n+    def query(self, *args, **kwargs):\n+        return self.call(None, 'query', *args, **kwargs)\n+\n+    def call(self, table, method, *args, **kwargs):\n+        payload = {\"table\": table, \"method\": method, \"args\": args, \"kwargs\": kwargs,\"call_uid\":None}\n+        payload[\"call_uid\"] = str(uuid.uuid4())    \n+        self.ws.write(json.dumps(payload))\n+        if payload[\"call_uid\"]:\n+            response = self.ws.read()\n+            return json.loads(response)['result']\n+        return True\n+\n+\n+\n+class DatasetWebSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        self.setattr(self, \"db\", self.get)\n+        self.setattr(self, \"db\", self.set)\n+        )\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                try:\n+                    data = json.loads(msg.data)\n+                    call_uid = data.get(\"call_uid\")\n+                    method = data.get(\"method\")\n+                    table_name = data.get(\"table\")\n+                    args = data.get(\"args\", {})\n+                    kwargs = data.get(\"kwargs\", {})\n+                    \n+\n+                    function = getattr(self.db, method, None)\n+                    if table_name:\n+                        function = getattr(self.db[table_name], method, None)\n+                    \n+                    print(method, table_name, args, kwargs,flush=True)\n+                    \n+                    if function:\n+                        response = {}\n+                        try:\n+                            result = function(*args, **kwargs)\n+                            print(result) \n+                            response['result'] = self.format_result(result)\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = True\n+                        except Exception as e:\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = False\n+                            response[\"error\"] = str(e)\n+                            response[\"traceback\"] = traceback.format_exc()\n+                        \n+                        if call_uid:\n+                            await self.send_str(json.dumps(response,default=str))\n+                    else:\n+                        await self.send_str(json.dumps({\"status\": \"error\", \"error\":\"Method not found.\",\"call_uid\": call_uid}))\n+                except Exception as e:\n+                    await self.send_str(json.dumps({\"success\": False,\"call_uid\": call_uid, \"error\": str(e), \"error\": str(e), \"traceback\": traceback.format_exc()},default=str))\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+\n+        return ws\n+\n+    \n+\n+    \n+\n+app = web.Application()\n+view = DatasetWebSocketView()\n+app.router.add_get('/db', view.handle)\n+\n+async def run_server():\n+\n+\n+    runner = web.AppRunner(app)\n+    await runner.setup()\n+    site = web.TCPSite(runner, 'localhost', 3131)\n+    await site.start()\n+\n+    await asyncio.Event().wait()\n+\n+async def client():\n+    print(\"x\")\n+    d = DatasetWrapper()\n+    print(\"y\")\n+    \n+    for x in range(100):\n+        for x in range(100):\n+            if d['test'].insert({\"name\": \"test\", \"number\":x}):\n+                print(\".\",end=\"\",flush=True)\n+        print(\"\")    \n+        print(d['test'].find_one(name=\"test\", order_by=\"-number\")) \n+\n+    print(\"DONE\")\n+\n+\n+\n+import time \n+async def main():\n+    await run_server()\n+\n+import sys \n+\n+if __name__ == '__main__':\n+    if sys.argv[1] == 'server':\n+        asyncio.run(main())\n+    if sys.argv[1] == 'client':\n+        asyncio.run(client())\ndiff --git a/src/snek/research/serptest.py b/src/snek/research/serptest.py\nnew file mode 100644\nindex 0000000..94e22be\n--- /dev/null\n+++ b/src/snek/research/serptest.py\n@@ -0,0 +1,51 @@\n+import snek.serpentarium\n+\n+import time \n+\n+from concurrent.futures import ProcessPoolExecutor\n+\n+durations = []\n+\n+def task1():\n+    global durations \n+    client = snek.serpentarium.DatasetWrapper()\n+\n+    start=time.time()\n+    for x in range(1500):\n+    \n+        client['a'].delete()\n+        client['a'].insert({\"foo\": x})\n+        client['a'].find(foo=x) \n+        client['a'].find_one(foo=x)\n+        client['a'].count()\n+    client.close()\n+    duration1 = f\"{time.time()-start}\"\n+    durations.append(duration1)\n+    print(durations)\n+\n+with ProcessPoolExecutor(max_workers=4) as executor:\n+    tasks = [executor.submit(task1),\n+        executor.submit(task1),\n+        executor.submit(task1),\n+        executor.submit(task1)\n+    ]\n+    for task in tasks:\n+        task.result()\n+\n+\n+import dataset \n+start=time.time()\n+for x in range(1500):\n+\n+    client['a'].delete()\n+    client['a'].insert({\"foo\": x})\n+    print([dict(row) for row in client['a'].find(foo=x)])\n+    print(dict(client['a'].find_one(foo=x) ))\n+    print(client['a'].count())\n+duration2 = f\"{time.time()-start}\"\n+\n+print(duration1,duration2)\ndiff --git a/src/snek/schema.sql b/src/snek/schema.sql\nnew file mode 100644\nindex 0000000..5b9c9a5\n--- /dev/null\n+++ b/src/snek/schema.sql\n@@ -0,0 +1,103 @@\n+CREATE TABLE IF NOT EXISTS http_access (\n+\tid INTEGER NOT NULL, \n+\tcreated TEXT, \n+\tpath TEXT, \n+\tduration FLOAT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE TABLE IF NOT EXISTS user (\n+\tid INTEGER NOT NULL, \n+\tcolor TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\temail TEXT, \n+\tis_admin TEXT, \n+\tlast_ping TEXT, \n+\tnick TEXT, \n+\tpassword TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tusername TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_user_e2577dd78b54fe28 ON user (uid);\n+CREATE TABLE IF NOT EXISTS channel (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tcreated_by_uid TEXT, \n+\tdeleted_at TEXT, \n+\tdescription TEXT, \n+\t\"index\" BIGINT, \n+\tis_listed BOOLEAN, \n+\tis_private BOOLEAN, \n+\tlabel TEXT, \n+\tlast_message_on TEXT, \n+\ttag TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_e2577dd78b54fe28 ON channel (uid);\n+CREATE TABLE IF NOT EXISTS channel_member (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tis_banned BOOLEAN, \n+\tis_moderator BOOLEAN, \n+\tis_muted BOOLEAN, \n+\tis_read_only BOOLEAN, \n+\tlabel TEXT, \n+\tnew_count BIGINT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_member_e2577dd78b54fe28 ON channel_member (uid);\n+CREATE TABLE IF NOT EXISTS broadcast (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tmessage TEXT, \n+\tcreated_at TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE TABLE IF NOT EXISTS channel_message (\n+\tid INTEGER NOT NULL, \n+\tchannel_uid TEXT, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\thtml TEXT, \n+\tmessage TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_channel_message_e2577dd78b54fe28 ON channel_message (uid);\n+CREATE TABLE IF NOT EXISTS notification (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tmessage TEXT, \n+\tobject_type TEXT, \n+\tobject_uid TEXT, \n+\tread_at TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_notification_e2577dd78b54fe28 ON notification (uid);\n+CREATE TABLE IF NOT EXISTS repository (\n+\tid INTEGER NOT NULL, \n+\tcreated_at TEXT, \n+\tdeleted_at TEXT, \n+\tis_private BIGINT, \n+\tname TEXT, \n+\tuid TEXT, \n+\tupdated_at TEXT, \n+\tuser_uid TEXT, \n+\tPRIMARY KEY (id)\n+);\n+CREATE INDEX IF NOT EXISTS ix_repository_e2577dd78b54fe28 ON repository (uid);\ndiff --git a/src/snek/snode.py b/src/snek/snode.py\nnew file mode 100644\nindex 0000000..ae42a1f\n--- /dev/null\n+++ b/src/snek/snode.py\n@@ -0,0 +1,116 @@\n+import aiohttp\n+\n+ENABLED = False\n+\n+import aiohttp\n+import asyncio\n+from aiohttp import web\n+\n+import sqlite3\n+\n+import dataset\n+from sqlalchemy import event\n+from sqlalchemy.engine import Engine\n+\n+import json \n+\n+queue = asyncio.Queue()\n+\n+class State:\n+    do_not_sync = False \n+\n+async def sync_service(app):\n+    if not ENABLED:\n+        return  \n+    session = aiohttp.ClientSession()\n+        async def receive():\n+\n+            queries_synced = 0\n+            async for msg in ws:\n+                if msg.type == aiohttp.WSMsgType.TEXT:\n+                    try:\n+                        data = json.loads(msg.data)\n+                        State.do_not_sync = True \n+                        app.db.execute(*data)\n+                        app.db.commit()\n+                        State.do_not_sync = False\n+                        queries_synced += 1\n+                        print(\"queries synced: \" + str(queries_synced))\n+                        print(*data)\n+                        await app.services.socket.broadcast_event()\n+                    except Exception as e:\n+                        print(e)\n+                    pass \n+                elif msg.type == aiohttp.WSMsgType.ERROR:\n+                    break\n+        async def write():\n+            while True:\n+                msg = await queue.get()\n+                await ws.send_str(json.dumps(msg,default=str))\n+                queue.task_done()\n+\n+        await asyncio.gather(receive(), write())\n+\n+    await session.close()\n+\n+queries_queued = 0\n+@event.listens_for(Engine, \"before_cursor_execute\")\n+def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):\n+    if not ENABLED:\n+        return\n+    global queries_queued\n+    if State.do_not_sync:\n+        print(statement,parameters)\n+        return\n+    if statement.startswith(\"SELECT\"):\n+        return\n+    queue.put_nowait((statement, parameters))\n+    queries_queued += 1\n+    print(\"Queries queued: \" + str(queries_queued))\n+\n+async def websocket_handler(request):\n+    queries_broadcasted = 0 \n+    ws = web.WebSocketResponse()\n+    await ws.prepare(request)\n+    request.app['websockets'].append(ws)\n+    async for msg in ws:\n+        if msg.type == aiohttp.WSMsgType.TEXT:\n+            for client in request.app['websockets']:\n+                if client != ws:\n+                    await client.send_str(msg.data)\n+            cursor = request.app['db'].cursor()\n+            data = json.loads(msg.data)\n+            queries_broadcasted += 1\n+\n+            cursor.execute(*data)\n+            cursor.close()\n+            print(\"Queries broadcasted: \" + str(queries_broadcasted))\n+        elif msg.type == aiohttp.WSMsgType.ERROR:\n+            print(f'WebSocket connection closed with exception {ws.exception()}')\n+\n+    request.app['websockets'].remove(ws)\n+    return ws\n+\n+app = web.Application()\n+app['websockets'] = []\n+\n+app.router.add_get('/ws', websocket_handler)\n+\n+async def on_startup(app):\n+    app['db'] = sqlite3.connect('snek.db')\n+    print(\"Server starting...\")\n+\n+async def on_cleanup(app):\n+    for ws in app['websockets']:\n+        await ws.close()\n+    app['db'].close()\n+\n+app.on_startup.append(on_startup)\n+app.on_cleanup.append(on_cleanup)\n+\n+\n+if __name__ == '__main__':\n+    web.run_app(app, host='127.0.0.1', port=3131)"}
{"repo": ".", "date": "2025-05-13", "line": "refactor: Moved WebSocketClient to system module", "commit": "adad5ed4fe37038442d247d9246795a82d31093c", "diff": "commit adad5ed4fe37038442d247d9246795a82d31093c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 19:08:18 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/research/serpentarium.py b/src/snek/research/serpentarium.py\nindex c87b70b..e425a23 100644\n--- a/src/snek/research/serpentarium.py\n+++ b/src/snek/research/serpentarium.py\n@@ -33,76 +33,6 @@ class DatasetTable:\n     def __getattr__(self, name):\n         return DatasetMethod(self, name)\n \n-class WebSocketClient:\n-    def __init__(self):\n-        self.buffer = b''\n-        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n-        self.connect()     \n-\n-    def connect(self):\n-        self.socket.connect((\"127.0.0.1\", 3131))\n-        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n-        handshake = (\n-            f\"GET /db HTTP/1.1\\r\\n\"\n-            f\"Host: localhost:3131\\r\\n\"\n-            f\"Upgrade: websocket\\r\\n\"\n-            f\"Connection: Upgrade\\r\\n\"\n-            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n-            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n-        )\n-        self.socket.sendall(handshake.encode('utf-8'))\n-        response = self.read_until(b'\\r\\n\\r\\n')\n-        if b'101 Switching Protocols' not in response:\n-            raise Exception(\"Failed to connect to WebSocket\")\n-\n-    def write(self, message):\n-        message_bytes = message.encode('utf-8')\n-        length = len(message_bytes)\n-        if length <= 125:\n-            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n-        elif length >= 126 and length <= 65535:\n-            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n-        else:\n-            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n-        \n-\n-    def read_until(self, delimiter): \n-        while True:\n-            find_pos = self.buffer.find(delimiter)\n-            if find_pos != -1:\n-                data = self.buffer[:find_pos+4]\n-                self.buffer = self.buffer[find_pos+4:]\n-                return data \n-            \n-            chunk = self.socket.recv(1024)\n-            if not chunk:\n-                return None\n-            self.buffer += chunk\n-            \n-    def read_exactly(self, length):\n-        while len(self.buffer) < length:\n-            chunk = self.socket.recv(length - len(self.buffer))\n-            if not chunk:\n-                return None\n-            self.buffer += chunk \n-        response = self.buffer[: length]\n-        self.buffer = self.buffer[length:]\n-        return response\n-\n-    def read(self):\n-        frame = None \n-        frame = self.read_exactly(2)\n-        length = frame[1] & 127\n-        if length == 126:\n-            length = int.from_bytes(self.read_exactly(2), 'big')\n-        elif length == 127:\n-            length = int.from_bytes(self.read_exactly(8), 'big')\n-        message = self.read_exactly(length)\n-        return message\n-    \n-    def close(self):\n-        self.socket.close()\n-\n \n \n \ndiff --git a/src/snek/system/websocket.py b/src/snek/system/websocket.py\nnew file mode 100644\nindex 0000000..c54b094\n--- /dev/null\n+++ b/src/snek/system/websocket.py\n@@ -0,0 +1,81 @@\n+class WebSocketClient:\n+    def __init__(self, hostname, port):\n+        self.buffer = b''\n+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n+        self.hostname = hostname \n+        self.port = port\n+        self.connect()     \n+    \n+    def __getattr__(self, method, *args, **kwargs):\n+        if method in self.__dict__.keys():\n+            return self.__dict__[method]\n+        def call(*args, **kwargs):\n+            self.write(json.dumps({'method': method, 'args': args, 'kwargs': kwargs}))\n+            return json.loads(self.read())\n+        return call\n+\n+    def connect(self):\n+        self.socket.connect((self.hostname, self.port))\n+        key = base64.b64encode(b'1234123412341234').decode('utf-8')\n+        handshake = (\n+            f\"GET /db HTTP/1.1\\r\\n\"\n+            f\"Host: localhost:3131\\r\\n\"\n+            f\"Upgrade: websocket\\r\\n\"\n+            f\"Connection: Upgrade\\r\\n\"\n+            f\"Sec-WebSocket-Key: {key}\\r\\n\"\n+            f\"Sec-WebSocket-Version: 13\\r\\n\\r\\n\"\n+        )\n+        self.socket.sendall(handshake.encode('utf-8'))\n+        response = self.read_until(b'\\r\\n\\r\\n')\n+        if b'101 Switching Protocols' not in response:\n+            raise Exception(\"Failed to connect to WebSocket\")\n+\n+    def write(self, message):\n+        message_bytes = message.encode('utf-8')\n+        length = len(message_bytes)\n+        if length <= 125:\n+            self.socket.sendall(b'\\x81' + bytes([length]) + message_bytes)\n+        elif length >= 126 and length <= 65535:\n+            self.socket.sendall(b'\\x81' + bytes([126]) + length.to_bytes(2, 'big') + message_bytes)\n+        else:\n+            self.socket.sendall(b'\\x81' + bytes([127]) + length.to_bytes(8, 'big') + message_bytes)\n+        \n+\n+    def read_until(self, delimiter): \n+        while True:\n+            find_pos = self.buffer.find(delimiter)\n+            if find_pos != -1:\n+                data = self.buffer[:find_pos+4]\n+                self.buffer = self.buffer[find_pos+4:]\n+                return data \n+            \n+            chunk = self.socket.recv(1024)\n+            if not chunk:\n+                return None\n+            self.buffer += chunk\n+            \n+    def read_exactly(self, length):\n+        while len(self.buffer) < length:\n+            chunk = self.socket.recv(length - len(self.buffer))\n+            if not chunk:\n+                return None\n+            self.buffer += chunk \n+        response = self.buffer[: length]\n+        self.buffer = self.buffer[length:]\n+        return response\n+\n+    def read(self):\n+        frame = None \n+        frame = self.read_exactly(2)\n+        length = frame[1] & 127\n+        if length == 126:\n+            length = int.from_bytes(self.read_exactly(2), 'big')\n+        elif length == 127:\n+            length = int.from_bytes(self.read_exactly(8), 'big')\n+        message = self.read_exactly(length)\n+        return message\n+    \n+    def close(self):\n+        self.socket.close()\n+\n+"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Remove timing output from task execution", "commit": "2e324ff11815d3c67fffa8e8d5f3e3554f154b57", "diff": "commit 2e324ff11815d3c67fffa8e8d5f3e3554f154b57\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 19:13:50 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex bbf6539..fa211d1 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -132,10 +132,7 @@ class Application(BaseApplication):\n             task = await self.tasks.get()\n             self.db.begin()\n             try:\n-                task_start = time.time()\n                 await task\n-                task_end = time.time()\n-                print(f\"Task {task} took {task_end - task_start} seconds\")\n                 self.tasks.task_done()\n             except Exception as ex:\n                 print(ex)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Implement image click overlay for full-size viewing", "commit": "d09055986e9a5d971f58075a5e939a268deb26be", "diff": "commit d09055986e9a5d971f58075a5e939a268deb26be\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:18:47 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 1046d2e..a862d21 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -314,6 +314,36 @@\n         }\n     });\n \n+    messagesContainer.addEventListener('click', (e) => {\n+       if(e.target.tagName != 'IMG')\n+           return\n+      const img = e.target\n+      const overlay = document.createElement('div');\n+      overlay.style.position = 'fixed';\n+      overlay.style.top = 0;\n+      overlay.style.left = 0;\n+      overlay.style.width = '100%';\n+      overlay.style.height = '100%';\n+      overlay.style.backgroundColor = 'rgba(0,0,0,0.9)';\n+      overlay.style.display = 'flex';\n+      overlay.style.justifyContent = 'center';\n+      overlay.style.alignItems = 'center';\n+      overlay.style.zIndex = 9999;\n+\n+      const fullImg = document.createElement('img');\n+      fullImg.src = img.src;\n+      fullImg.alt = img.alt;\n+      fullImg.style.maxWidth = '90%';\n+      fullImg.style.maxHeight = '90%';\n+\n+      overlay.appendChild(fullImg);\n+\n+      document.body.appendChild(overlay);\n+\n+      overlay.addEventListener('click', () => {\n+        document.body.removeChild(overlay);\n+      });\n+    });\n     initInputField(getInputField());\n     updateLayout(true);\n </script>"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image embeds", "commit": "8cd2f16c5c46318cb035b882197b516ae5532452", "diff": "commit 8cd2f16c5c46318cb035b882197b516ae5532452\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:20:43 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 93fd33c..27dec46 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -123,7 +123,7 @@ def embed_image(text):\n                 \".heic\",\n             ]\n         ):\n-            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}\" alt=\"{element.attrs[\"href\"]}\" />'\n+            embed_template = f'<img src=\"{element.attrs[\"href\"]}\" title=\"{element.attrs[\"href\"]}?width=420\" alt=\"{element.attrs[\"href\"]}\" />'\n             element.replace_with(BeautifulSoup(embed_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added width attribute to image source", "commit": "015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda", "diff": "commit 015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:24:29 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 27dec46..d69f568 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]?width=420}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected image width attribute in template rendering", "commit": "12d287042415554c581e7d4fcfc81bd3d733fa02", "diff": "commit 12d287042415554c581e7d4fcfc81bd3d733fa02\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:25:00 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d69f568..40b5f2d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]?width=420}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]}?width=420\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image source", "commit": "964a747f42ade75dcfced5395f46727f0508172c", "diff": "commit 964a747f42ade75dcfced5395f46727f0508172c\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:27:26 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 40b5f2d..d018ee8 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,11 +131,12 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n+            element.attrs[\"src\"].append(\"?width=420\")\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n                                     <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n-                                    <img src=\"{element.attrs[\"src\"]}?width=420\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n+                                    <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))\n     return str(soup)"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image sources", "commit": "319c1b1b5264933a7ea1d7af6541be2a410a3328", "diff": "commit 319c1b1b5264933a7ea1d7af6541be2a410a3328\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:28:31 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex d018ee8..7e9b316 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"].append(\"?width=420\")\n+            element.attrs[\"src\"] += \"?width=420\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected webp image type in template", "commit": "a21e3590ef4ddad292fb914cb3454d07eb622413", "diff": "commit a21e3590ef4ddad292fb914cb3454d07eb622413\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:30:48 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7e9b316..bad98fc 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -135,7 +135,7 @@ def enrich_image_rendering(text):\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\n-                                    <source srcset=\"{element.attrs[\"src\"]}?format=webp\" type=\"image/webp\" />\n+                                    <source srcset=\"{element.attrs[\"src\"]}\" type=\"image/webp\" />\n                                     <img src=\"{element.attrs[\"src\"]}\" title=\"{element.attrs[\"src\"]}\" alt=\"{element.attrs[\"src\"]}\" />\n                                 </picture>'''\n             element.replace_with(BeautifulSoup(picture_template, \"html.parser\"))"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Reduce image width and fix image URL in web template", "commit": "b55d74fb124b90ee24d158bc94c401b0ff19edb9", "diff": "commit b55d74fb124b90ee24d158bc94c401b0ff19edb9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 20:35:42 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex bad98fc..7026e6d 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"] += \"?width=420\"\n+            element.attrs[\"src\"] += \"?width=240\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex a862d21..689efd3 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -331,7 +331,11 @@\n       overlay.style.zIndex = 9999;\n \n       const fullImg = document.createElement('img');\n-      fullImg.src = img.src;\n+\n+      const urlObj = new URL(img.src);\n+      urlObj.search = ''\n+        fullImg.src = urlObj.toString();\n+\n       fullImg.alt = img.alt;\n       fullImg.style.maxWidth = '90%';\n       fullImg.style.maxHeight = '90%';"}
{"repo": ".", "date": "2025-05-13", "line": "style: Added cursor pointer to chat message images", "commit": "3858dcbd62e4032a02e9d25dffd000ade4dc7bbe", "diff": "commit 3858dcbd62e4032a02e9d25dffd000ade4dc7bbe\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 21:25:49 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 90f20b4..54bc61a 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -151,7 +151,9 @@ footer {\n   }\n \n }\n-\n+.chat-messages > picture > img {    \n+    cursor: pointer;\n+}\n .chat-messages::-webkit-scrollbar {\n   display: none;\n }"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add height parameter to image rendering", "commit": "0ea0cd96dbe536b09cb3549de47766a756f04008", "diff": "commit 0ea0cd96dbe536b09cb3549de47766a756f04008\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 22:54:21 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/template.py b/src/snek/system/template.py\nindex 7026e6d..8bd1ca3 100644\n--- a/src/snek/system/template.py\n+++ b/src/snek/system/template.py\n@@ -131,7 +131,7 @@ def enrich_image_rendering(text):\n     soup = BeautifulSoup(text, \"html.parser\")\n     for element in soup.find_all(\"img\"):\n         if element.attrs[\"src\"].startswith(\"/\"   ):\n-            element.attrs[\"src\"] += \"?width=240\"\n+            element.attrs[\"src\"] += \"?width=240&height=240\"\n             picture_template = f'''\n                                 <picture>\n                                     <source srcset=\"{element.attrs[\"src\"]}\" type=\"{mimetypes.guess_type(element.attrs[\"src\"])[0]}\" />"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Encode attachment URLs for safe transmission", "commit": "af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9", "diff": "commit af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Tue May 13 23:33:24 2025 +0200\n\n    Push\n\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 86ed7a0..9d36d8f 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -8,7 +8,7 @@ from snek.system.view import BaseView\n import aiofiles\n from aiohttp import web\n import pathlib\n-\n+import urllib.parse \n \n class ChannelAttachmentView(BaseView):\n     async def get(self):\n@@ -116,10 +116,16 @@ class ChannelAttachmentView(BaseView):\n                 while chunk := await field.read_chunk():\n                     await f.write(chunk)\n \n+        attachment_records = []\n+        for attachment in attachments:\n+            attachment_record = attachment.record\n+            attachment_record['relative_url'] = urllib.parse.quote(attachment_record['relative_url'])\n+            attachment_records.append(attachment_record)\n+\n         return web.json_response(\n             {\n                 \"message\": \"Files uploaded successfully\",\n-                \"files\": [attachment.record for attachment in attachments],\n+                \"files\": attachment_records,\n                 \"channel_uid\": channel_uid,\n             }\n         )"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Introduce online user list and typing indicator", "commit": "db6d6c0106267f56822ae378a4c88385d025051a", "diff": "commit db6d6c0106267f56822ae378a4c88385d025051a\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 13:18:53 2025 +0200\n\n    Update live type.\n\ndiff --git a/src/snek/balancer.py b/src/snek/balancer.py\nnew file mode 100644\nindex 0000000..09d0b5a\n--- /dev/null\n+++ b/src/snek/balancer.py\n@@ -0,0 +1,123 @@\n+import asyncio\n+import sys\n+\n+class LoadBalancer:\n+    def __init__(self, backend_ports):\n+        self.backend_ports = backend_ports\n+        self.backend_processes = []\n+        self.client_counts = [0] * len(backend_ports)\n+        self.lock = asyncio.Lock()\n+\n+    async def start_backend_servers(self,port,workers):\n+        for x in range(workers):\n+            port += 1\n+            process = await asyncio.create_subprocess_exec(\n+                sys.executable,\n+                sys.argv[0],\n+                'backend',\n+                str(port),\n+                stdout=asyncio.subprocess.PIPE,\n+                stderr=asyncio.subprocess.PIPE\n+            )\n+            port += 1\n+            self.backend_processes.append(process)\n+            print(f\"Started backend server on port {(port-1)/port} with PID {process.pid}\")\n+\n+    async def handle_client(self, reader, writer):\n+        async with self.lock:\n+            min_clients = min(self.client_counts)\n+            server_index = self.client_counts.index(min_clients)\n+            self.client_counts[server_index] += 1\n+        backend = ('127.0.0.1', self.backend_ports[server_index])\n+        try:\n+            backend_reader, backend_writer = await asyncio.open_connection(*backend)\n+\n+            async def forward(r, w):\n+                try:\n+                    while True:\n+                        data = await r.read(1024)\n+                        if not data:\n+                            break\n+                        w.write(data)\n+                        await w.drain()\n+                except asyncio.CancelledError:\n+                    pass\n+                finally:\n+                    w.close()\n+\n+            task1 = asyncio.create_task(forward(reader, backend_writer))\n+            task2 = asyncio.create_task(forward(backend_reader, writer))\n+            await asyncio.gather(task1, task2)\n+        except Exception as e:\n+            print(f\"Error: {e}\")\n+        finally:\n+            writer.close()\n+            async with self.lock:\n+                self.client_counts[server_index] -= 1\n+\n+    async def monitor(self):\n+        while True:\n+            await asyncio.sleep(5)\n+            print(\"Connected clients per server:\")\n+            for i, count in enumerate(self.client_counts):\n+                print(f\"Server {self.backend_ports[i]}: {count} clients\")\n+\n+    async def start(self, host='0.0.0.0', port=8081,workers=5):\n+        await self.start_backend_servers(port,workers)\n+        server = await asyncio.start_server(self.handle_client, host, port)\n+        monitor_task = asyncio.create_task(self.monitor())\n+\n+        try:\n+            async with server:\n+                await server.serve_forever()\n+        except asyncio.CancelledError:\n+            pass\n+        finally:\n+            for process in self.backend_processes:\n+                process.terminate()\n+            await asyncio.gather(*(p.wait() for p in self.backend_processes))\n+            print(\"Backend processes terminated.\")\n+\n+async def backend_echo_server(port):\n+    async def handle_echo(reader, writer):\n+        try:\n+            while True:\n+                data = await reader.read(1024)\n+                if not data:\n+                    break\n+                writer.write(data)\n+                await writer.drain()\n+        except Exception:\n+            pass\n+        finally:\n+            writer.close()\n+\n+    server = await asyncio.start_server(handle_echo, '127.0.0.1', port)\n+    print(f\"Backend echo server running on port {port}\")\n+    await server.serve_forever()\n+\n+async def main():\n+    backend_ports = [8001, 8003, 8005, 8006]\n+    lb = LoadBalancer(backend_ports)\n+    await lb.start()\n+\n+if __name__ == \"__main__\":\n+    if len(sys.argv) > 1:\n+        if sys.argv[1] == 'backend':\n+            port = int(sys.argv[2])\n+            from snek.app import Application\n+            snek = Application(port=port)\n+            web.run_app(snek, port=port, host='127.0.0.1')\n+        elif sys.argv[1] == 'sync':\n+            from snek.sync import app\n+            web.run_app(snek, port=port, host='127.0.0.1')\n+    else:\n+        try:\n+            asyncio.run(main())\n+        except KeyboardInterrupt:\n+            print(\"Shutting down...\")\n+\ndiff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py\nindex f8a000f..d9ba45d 100644\n--- a/src/snek/service/channel_message.py\n+++ b/src/snek/service/channel_message.py\n@@ -30,7 +30,7 @@ class ChannelMessageService(BaseService):\n         except Exception as ex:\n             print(ex, flush=True)\n \n-        if await self.save(model):\n+        if await super().save(model):\n             return model\n         raise Exception(f\"Failed to create channel message: {model.errors}.\")\n \n@@ -50,6 +50,12 @@ class ChannelMessageService(BaseService):\n             \"username\": user[\"username\"],\n         }\n \n+    async def save(self, model):\n+        context = model.record  \n+        template = self.app.jinja2_env.get_template(\"message.html\")\n+        model[\"html\"] = template.render(**context)\n+        return await super().save(model)\n+\n     async def offset(self, channel_uid, page=0, timestamp=None, page_size=30):\n         results = []\n         offset = page * page_size\ndiff --git a/src/snek/service/chat.py b/src/snek/service/chat.py\nindex 388d5c0..7fbe787 100644\n--- a/src/snek/service/chat.py\n+++ b/src/snek/service/chat.py\n@@ -36,4 +36,4 @@ class ChatService(BaseService):\n             self.services.notification.create_channel_message(channel_message_uid)\n         )\n \n-        return True\n+        return channel_message\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 91d80d3..210c2e8 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -5,8 +5,64 @@\n \n+ import {app} from '../app.js'\n+ class MessageList extends HTMLElement {\n+      constructor() {\n+        super();\n+        app.ws.addEventListener(\"update_message_text\",(data)=>{\n+            this.updateMessageText(data.data.message_uid,data.data.text)\n+        })\n+          app.ws.addEventListener(\"set_typing\",(data)=>{\n+\t\t    this.triggerGlow(data.data.user_uid)\t\n+\n+\t    })\n+\n+          this.items = [];\n+      }\n+      updateMessageText(uid,text){\n+        const messageDiv = this.querySelector(\"div[data-uid=\\\"\"+uid+\"\\\"]\")\n+          if(!messageDiv){\n+            return\n+          }\n+          const textElement = messageDiv.querySelector(\".text\")\n+          textElement.innerText = text \n+          textElement.style.display = text == '' ? 'none' : 'block'\n+        \n+      }\n+        triggerGlow(uid) {\n+\t \tlet lastElement = null;\n+            this.querySelectorAll(\".avatar\").forEach((el)=>{\n+\t\t   const div = el.closest('a');\n+\t\t   if(el.href.indexOf(uid)!=-1){\n+\t\t\tlastElement = el\n+            }    \t\t    \n+\n+\t      })\n+        if(lastElement){\n+            lastElement.classList.add(\"glow\")\n+            setTimeout(()=>{\n+                lastElement.classList.remove(\"glow\")\n+            },1000)\n+        }\n+ \t\n+    \t}\n+\n+      set data(items) {\n+        this.items = items;\n+          this.render();\n+      }\n+      render() {\n+        this.innerHTML = '';\n+\n+        \n+      }\n+\n+    }\n+\n+    customElements.define('message-list', MessageList);\n \n-class MessageListElement extends HTMLElement {\n+class MessageListElementOLD extends HTMLElement {\n     static get observedAttributes() {\n         return [\"messages\"];\n     }\n@@ -167,4 +223,4 @@ class MessageListElement extends HTMLElement {\n     }\n }\n \n-customElements.define('message-list', MessageListElement);\ndiff --git a/src/snek/static/online-users.js b/src/snek/static/online-users.js\nnew file mode 100644\nindex 0000000..8b13789\n--- /dev/null\n+++ b/src/snek/static/online-users.js\n@@ -0,0 +1 @@\n+\ndiff --git a/src/snek/static/user-list.css b/src/snek/static/user-list.css\nnew file mode 100644\nindex 0000000..d831c2f\n--- /dev/null\n+++ b/src/snek/static/user-list.css\n@@ -0,0 +1,28 @@\n+   .user-list__item {\n+      display: flex;\n+      margin-bottom: 1em;\n+      padding: 10px;\n+      border-radius: 8px;\n+    }\n+    .user-list__item-avatar {\n+      margin-right: 10px;\n+      border-radius: 50%;\n+      overflow: hidden;\n+      width: 40px;\n+      height: 40px;\n+      display: block;\n+    }\n+    .user-list__item-content {\n+      flex: 1;\n+    }\n+    .user-list__item-name {\n+      font-weight: bold;\n+    }\n+    .user-list__item-text {\n+      margin: 5px 0;\n+    }\n+    .user-list__item-time {\n+      font-size: 0.8em;\n+      color: gray;\n+    }\ndiff --git a/src/snek/static/user-list.js b/src/snek/static/user-list.js\nnew file mode 100644\nindex 0000000..5aaba50\n--- /dev/null\n+++ b/src/snek/static/user-list.js\n@@ -0,0 +1,59 @@\n+ class UserList extends HTMLElement {\n+      constructor() {\n+        super();\n+        this.users = [];\n+      }\n+\n+      set data(userArray) {\n+        this.users = userArray;\n+          this.render();\n+      }\n+\n+      formatRelativeTime(timestamp) {\n+        const now = new Date();\n+        const msgTime = new Date(timestamp);\n+        const diffMs = now - msgTime;\n+        const minutes = Math.floor(diffMs / 60000);\n+        const hours = Math.floor(minutes / 60);\n+        const days = Math.floor(hours / 24);\n+\n+        if (days > 0) {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;\n+        } else if (hours > 0) {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;\n+        } else {\n+          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;\n+        }\n+      }\n+\n+      render() {\n+        this.innerHTML = '';\n+\n+        this.users.forEach(user => {\n+          const html = `\n+            <div class=\"user-list__item\"\n+              data-uid=\"${user.uid}\"\n+              data-color=\"${user.color}\"\n+              data-user_nick=\"${user.nick}\"\n+              data-created_at=\"${user.created_at}\"\n+              data-user_uid=\"${user.user_uid}\">\n+              \n+              <a class=\"user-list__item-avatar\" style=\"background-color: ${user.color}; color: black;\" href=\"/user/${user.uid}.html\">\n+                <img width=\"40px\" height=\"40px\" src=\"/avatar/${user.uid}.svg\" alt=\"${user.nick}\">\n+              </a>\n+              \n+              <div class=\"user-list__item-content\">\n+                <div class=\"user-list__item-name\" style=\"color: ${user.color};\">${user.nick}</div>\n+                <div class=\"user-list__item-time\" data-created_at=\"${user.last_ping}\">\n+                  <a href=\"/user/${user.uid}.html\">profile</a>\n+                  <a href=\"/channel/${user.uid}.html\">dm</a>\n+                </div>\n+              </div>\n+            </div>\n+          `;\n+          this.insertAdjacentHTML(\"beforeend\", html);\n+        });\n+      }\n+    }\n+\n+    customElements.define('user-list', UserList);\ndiff --git a/src/snek/sync.py b/src/snek/sync.py\nnew file mode 100644\nindex 0000000..fb2a9af\n--- /dev/null\n+++ b/src/snek/sync.py\n@@ -0,0 +1,135 @@\n+\n+\n+\n+\n+class DatasetWebSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        self.setattr(self, \"db\", self.get)\n+        self.setattr(self, \"db\", self.set)\n+        )\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                try:\n+                    data = json.loads(msg.data)\n+                    call_uid = data.get(\"call_uid\")\n+                    method = data.get(\"method\")\n+                    table_name = data.get(\"table\")\n+                    args = data.get(\"args\", {})\n+                    kwargs = data.get(\"kwargs\", {})\n+                    \n+\n+                    function = getattr(self.db, method, None)\n+                    if table_name:\n+                        function = getattr(self.db[table_name], method, None)\n+                    \n+                    print(method, table_name, args, kwargs,flush=True)\n+                    \n+                    if function:\n+                        response = {}\n+                        try:\n+                            result = function(*args, **kwargs)\n+                            print(result) \n+                            response['result'] = self.format_result(result)\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = True\n+                        except Exception as e:\n+                            response[\"call_uid\"] = call_uid\n+                            response[\"success\"] = False\n+                            response[\"error\"] = str(e)\n+                            response[\"traceback\"] = traceback.format_exc()\n+                        \n+                        if call_uid:\n+                            await self.send_str(json.dumps(response,default=str))\n+                    else:\n+                        await self.send_str(json.dumps({\"status\": \"error\", \"error\":\"Method not found.\",\"call_uid\": call_uid}))\n+                except Exception as e:\n+                    await self.send_str(json.dumps({\"success\": False,\"call_uid\": call_uid, \"error\": str(e), \"error\": str(e), \"traceback\": traceback.format_exc()},default=str))\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+\n+        return ws\n+\n+ class BroadCastSocketView:\n+    def __init__(self):\n+        self.ws = None\n+        super()\n+    \n+    def format_result(self, result):\n+        \n+        try:\n+            return dict(result)\n+        except:\n+            pass\n+        try:\n+            return [dict(row) for row in result]\n+        except:\n+            pass\n+        return result\n+\n+    async def send_str(self, msg):\n+        return await self.ws.send_str(msg)\n+\n+    def get(self, key):\n+        returnl loads(dict(self.db['_kv'].get(key=key)['value']))\n+\n+    def set(self, key, value):\n+        return self.db['_kv'].upsert({'key': key, 'value': json.dumps(value)}, ['key'])\n+\n+\n+\n+    async def handle(self, request):\n+        ws = web.WebSocketResponse()\n+        await ws.prepare(request)\n+        self.ws = ws\n+        app = request.app\n+        app['broadcast_clients'].append(ws)\n+\n+        async for msg in ws:\n+            if msg.type == aiohttp.WSMsgType.TEXT:\n+                print(msg.data)\n+                for client in app['broadcast_clients'] if not client == ws:\n+                    await client.send_str(msg.data)\n+            elif msg.type == aiohttp.WSMsgType.ERROR:\n+                print('ws connection closed with exception %s' % ws.exception())\n+        app['broadcast_clients'].remove(ws)\n+        return ws\n+    \n+\n+app = web.Application()\n+view = DatasetWebSocketView()\n+app['broadcast_clients'] = []\n+app.router.add_get('/db', view.handle)\n+app.router.add_get('/broadcast', sync_view.handle)\n+\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex a373c2d..7baa67a 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -16,6 +16,10 @@\n   <script src=\"/html-frame.js\" type=\"module\"></script>\n   <script src=\"/app.js\" type=\"module\"></script>\n   <script src=\"/file-manager.js\" type=\"module\"></script>\n+  <script src=\"/user-list.js\"></script>\n+  <script src=\"/message-list.js\" type=\"module\"></script>\n+  <link rel=\"stylesheet\" href=\"/user-list.css\">\n+\n   <link rel=\"stylesheet\" href=\"/base.css\">\n <link\n     rel=\"stylesheet\"\ndiff --git a/src/snek/templates/online.html b/src/snek/templates/online.html\nnew file mode 100644\nindex 0000000..a241662\n--- /dev/null\n+++ b/src/snek/templates/online.html\n@@ -0,0 +1,117 @@\n+<style>\n+  position: fixed;\n+  top: 50%;\n+  left: 50%;\n+  transform: translate(-50%, -50%);\n+\n+  border: none;\n+  border-radius: 12px;\n+  padding: 24px;\n+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n+  width: 90%;\n+  max-width: 400px;\n+\n+  animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out;\n+  z-index: 1000;\n+}\n+\n+  background: rgba(0, 0, 0, 0.7);\n+  backdrop-filter: blur(4px);\n+}\n+\n+  font-size: 1.5rem;\n+  font-weight: bold;\n+  margin-bottom: 16px;\n+}\n+\n+  font-size: 1rem;\n+  margin-bottom: 20px;\n+}\n+\n+  display: flex;\n+  justify-content: flex-end;\n+  gap: 10px;\n+}\n+\n+  padding: 8px 16px;\n+  font-size: 0.95rem;\n+  border-radius: 8px;\n+  border: none;\n+  cursor: pointer;\n+  transition: background 0.2s ease;\n+}\n+\n+  color: white;\n+}\n+\n+}\n+\n+}\n+\n+}\n+\n+@keyframes fadeIn {\n+  from { opacity: 0; }\n+  to { opacity: 1; }\n+}\n+\n+@keyframes scaleIn {\n+  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n+  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n+}\n+</style>\n+\n+\n+<dialog id=\"online-users\">\n+    <div class=\"dialog-backdrop\">\n+      <div class=\"dialog-box\">\n+        <div class=\"dialog-title\"><h2>Currently online</h2></div>\n+        <div class=\"dialog-content\"><user-list></user-list></div>\n+        <div class=\"dialog-actions\">\n+          <button class=\"dialog-button primary\">Close</button>\n+        </div>\n+      </div>\n+    </div>\n+  </dialog>\n+\n+<script>\n+const onlineDialog = document.getElementById(\"online-users\");\n+const dialogButton = onlineDialog.querySelector('.dialog-button.primary');\n+\n+dialogButton.addEventListener('click', () => {\n+    onlineDialog.close();\n+});\n+\n+async function showOnlineUsers() {\n+    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n+    onlineDialog.querySelector('user-list').data = users;\n+    onlineDialog.showModal();\n+}\n+</script>\n+\n+\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 689efd3..38f723c 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -9,19 +9,19 @@\n \n \n <section class=\"chat-area\">\n-    <div class=\"chat-messages\">\n-        {% for message in messages %}\n-            {% autoescape false %}\n-            {{ message.html }}\n-            {% endautoescape %}\n-        {% endfor %}\n-    </div>\n+    <message-list class=\"chat-messages\">\n+    {% for message in messages %}\n+        {% autoescape false %}\n+        {{ message.html }}\n+        {% endautoescape %}\n+    {% endfor %}\n+    </message-list>\n     <div class=\"chat-input\">\n-        <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n+        <textarea list=\"chat-input-autocomplete-items\" placeholder=\"Type a message...\" rows=\"2\" autocomplete=\"on\"></textarea>\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n-\n+{% include \"online.html\" %}\n <script type=\"module\">\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n@@ -30,18 +30,95 @@\n     function getInputField(){\n         return document.querySelector(\"textarea\")\n     }\n-    \n+    getInputField().autoComplete = {\n+        \"/online\": () =>{\n+            showOnlineUsers();\n+        },\n+        \"/clear\": () => {\n+            document.querySelector(\".chat-messages\").innerHTML = '';\n+        },\n+        \"/live\": () =>{\n+            getInputField().liveType = !getInputField().liveType\n+        }\n+    }\n+\n+\n     function initInputField(textBox) {\n-        textBox.addEventListener('keydown', (e) => {\n+        if(textBox.liveType == undefined){\n+            textBox.liveType = false\n+        }\n+        textBox.addEventListener('keydown',async (e) => {\n+            if(e.key === \"ArrowUp\"){\n+                const value = findDivAboveText(e.target.value).querySelector('.text')\n+                e.target.value = value.textContent\n+                console.info(\"HIERR\")\n+                return\n+            }\n+            if (e.key === \"Tab\") {\n+\n+                const message = e.target.value.trim();\n+                if (!message) {\n+                    return\n+                }\n+                let autoCompleteHandler = null;\n+                Object.keys(e.target.autoComplete).forEach((key)=>{\n+                    if(key.startsWith(message)){\n+                        if(autoCompleteHandler){\n+                            return \n+                        }\n+                        autoCompleteHandler = key\n+                    }\n+                })\n+                if(autoCompleteHandler){\n+                    e.preventDefault();\n+                    e.target.value = autoCompleteHandler;\n+                    return\n+                }\n+            }\n             if (e.key === 'Enter' && !e.shiftKey) {\n                 e.preventDefault();\n                 const message = e.target.value.trim();\n-                if (message) {\n-                    app.rpc.sendMessage(channelUid, message);\n+                if (!message) {\n+                    return\n+                }\n+                let autoCompleteHandler = e.target.autoComplete[message]\n+                if(autoCompleteHandler){\n+                    const value = message;\n                     e.target.value = '';\n+                    autoCompleteHandler(value)\n+                    return\n+                }\n+\n+                e.target.value = '';\n+                if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){\n+                        \n+                \n+                    app.rpc.updateMessageText(textBox.lastMessageUid, message)\n+                textBox.lastMessageUid = null\n+                    return \n                 }\n+\n+                const messageResponse = await app.rpc.sendMessage(channelUid, message);\n+                \n \t    }else{\n-\t\tapp.rpc.set_typing(channelUid)\n+\t\tif(textBox.liveType){\n+            \n+            if(e.target.value[0] == \"/\"){\n+                return\n+            }\n+            if(!textBox.lastMessageUid){\n+                textBox.lastMessageUid = '?'\n+                app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{\n+                    textBox.lastMessageUid = messageResponse\n+                })\n+            }\n+            if(textBox.lastMessageUid == '?'){\n+                return;\n+            }\n+            app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n+        }\n+        app.rpc.set_typing(channelUid)\n+        \n \t    }\n         });\n         document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n@@ -81,29 +158,7 @@\n             }\n         });\n \n-\t function triggerGlow(uid) {\n-\t \tdocument.querySelectorAll(\".avatar\").forEach((el)=>{\n-\t\t   const div = el.closest('a');\n-\t\t   if(el.href.indexOf(uid)!=-1){\n-\t\t\tel.classList.add('glow')\n-\t\t    \tlet originalColor = el.style.backgroundColor \n-\t\t\tsetTimeout(()=>{\n-\t\t\t\tel.classList.remove('glow')\n-\t\t\t},1200)\n-\t\t    }\n-\n-\t      })\n- \t\n-    \t}\n-\tapp.ws.addEventListener(\"set_typing\",(data)=>{\n-\t\ttriggerGlow(data.data.user_uid)\t\n-\n-\t})\n-\t\t\n+\t\t\t\n \n         const chatInput = document.querySelector(\".chat-area\")\n         chatInput.addEventListener(\"drop\", async (e) => {\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 645ccbc..0469e53 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -16,6 +16,9 @@ from snek.system.model import now\n from snek.system.profiler import Profiler\n from snek.system.view import BaseView\n \n+import logging \n+\n+logger = logging.getLogger(__name__)\n \n class RPCView(BaseView):\n \n@@ -170,11 +173,34 @@ class RPCView(BaseView):\n                 )\n             return channels\n \n-        async def send_message(self, channel_uid, message):\n+        async def update_message_text(self,message_uid, text):\n             self._require_login()\n-            await self.services.chat.send(self.user_uid, channel_uid, message)\n+            message = await self.services.channel_message.get(message_uid)    \n+            if message[\"user_uid\"] != self.user_uid:\n+                raise Exception(\"Not allowed\")\n+            await self.services.socket.broadcast(message[\"channel_uid\"], {\n+                \"channel_uid\": message[\"channel_uid\"],\n+                \"event\": \"update_message_text\",\n+                \"data\": {\n+                     \n+                    \"message_uid\": message_uid,\n+                    \"text\": text\n+                }\n+            })\n+            message[\"message\"] = text\n+            if not text:\n+                message['deleted_at'] = now()\n+            else:\n+                message['deleted_at'] = None\n+            await self.services.channel_message.save(message)\n             return True\n \n+        async def send_message(self, channel_uid, message):\n+            self._require_login()\n+            message = await self.services.chat.send(self.user_uid, channel_uid, message)\n+            \n+            return message[\"uid\"]\n+\n         async def echo(self, *args):\n             self._require_login()\n             return args\n@@ -243,12 +269,14 @@ class RPCView(BaseView):\n                 except Exception as ex:\n                     result = {\"exception\": str(ex), \"traceback\": traceback.format_exc()}\n                     success = False\n+                    logger.exception(ex)\n                 if result != \"noresponse\":\n                     await self._send_json(\n                         {\"callId\": call_id, \"success\": success, \"data\": result}\n                     )\n             except Exception as ex:\n                 print(str(ex), flush=True)\n+                logger.exception(ex)\n                 await self._send_json(\n                     {\"callId\": call_id, \"success\": False, \"data\": str(ex)}\n                 )\n@@ -259,15 +287,15 @@ class RPCView(BaseView):\n         async def get_online_users(self, channel_uid):\n             self._require_login()\n \n-            return [\n-                {\n-                    \"uid\": record[\"uid\"],\n-                    \"username\": record[\"username\"],\n-                    \"nick\": record[\"nick\"],\n-                    \"last_ping\": record[\"last_ping\"],\n-                }\n-                async for record in self.services.channel.get_online_users(channel_uid)\n+            results = [\n+                    record.record async for record  in self.services.channel.get_online_users(channel_uid)\n             ]\n+            for result in results:\n+                del result['email']\n+                del result['password']\n+                del result['deleted_at']\n+                del result['updated_at']\n+            return results\n \n         async def echo(self, obj):\n             await self.ws.send_json(obj)\n@@ -314,6 +342,7 @@ class RPCView(BaseView):\n                         await rpc(msg.json())\n                 except Exception as ex:\n                     print(\"Deleting socket\", ex, flush=True)\n+                    logger.exception(ex)\n                     await self.services.socket.delete(ws)\n                     break\n             elif msg.type == web.WSMsgType.ERROR:"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Update message text and add seconds since last update check\n\nThis commit introduces a check to prevent updating messages that are too old, enhancing message integrity and preventing potential issues with outdated data. It also updates the message text and broadcasts the update to the relevant channel. The UI has been updated to display the html of the message.", "commit": "25d109beedf030523ac5d357dbbb1f9efb919edb", "diff": "commit 25d109beedf030523ac5d357dbbb1f9efb919edb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 19:32:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/model/channel_message.py b/src/snek/model/channel_message.py\nindex 524a8a4..7fd1a27 100644\n--- a/src/snek/model/channel_message.py\n+++ b/src/snek/model/channel_message.py\n@@ -1,6 +1,6 @@\n from snek.model.user import UserModel\n from snek.system.model import BaseModel, ModelField\n-\n+from datetime import datetime,timezone \n \n class ChannelMessageModel(BaseModel):\n     channel_uid = ModelField(name=\"channel_uid\", required=True, kind=str)\n@@ -8,6 +8,9 @@ class ChannelMessageModel(BaseModel):\n     message = ModelField(name=\"message\", required=True, kind=str)\n     html = ModelField(name=\"html\", required=False, kind=str)\n \n+    def get_seconds_since_last_update(self):\n+        return int((datetime.now(timezone.utc) - datetime.fromisoformat(self[\"updated_at\"])).total_seconds())\n+\n     async def get_user(self) -> UserModel:\n         return await self.app.services.user.get(uid=self[\"user_uid\"])\n \ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 54bc61a..479d36b 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -151,7 +151,7 @@ footer {\n   }\n \n }\n-.chat-messages > picture > img {    \n+.chat-messages picture img {    \n     cursor: pointer;\n }\n .chat-messages::-webkit-scrollbar {\ndiff --git a/src/snek/static/message-list.js b/src/snek/static/message-list.js\nindex 210c2e8..6415a34 100644\n--- a/src/snek/static/message-list.js\n+++ b/src/snek/static/message-list.js\n@@ -10,7 +10,7 @@\n       constructor() {\n         super();\n         app.ws.addEventListener(\"update_message_text\",(data)=>{\n-            this.updateMessageText(data.data.message_uid,data.data.text)\n+            this.updateMessageText(data.data.uid,data.data)\n         })\n           app.ws.addEventListener(\"set_typing\",(data)=>{\n \t\t    this.triggerGlow(data.data.user_uid)\t\n@@ -19,14 +19,18 @@\n \n           this.items = [];\n       }\n-      updateMessageText(uid,text){\n+      updateMessageText(uid,message){\n         const messageDiv = this.querySelector(\"div[data-uid=\\\"\"+uid+\"\\\"]\")\n+\n           if(!messageDiv){\n             return\n           }\n+          const receivedHtml = document.createElement(\"div\")\n+          receivedHtml.innerHTML = message.html\n+          const html = receivedHtml.querySelector(\".text\").innerHTML\n           const textElement = messageDiv.querySelector(\".text\")\n-          textElement.innerText = text \n-          textElement.style.display = text == '' ? 'none' : 'block'\n+          textElement.innerHTML = html \n+          textElement.style.display = message.text == '' ? 'none' : 'block'\n         \n       }\n         triggerGlow(uid) {\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 38f723c..113b77d 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -47,7 +47,18 @@\n         if(textBox.liveType == undefined){\n             textBox.liveType = false\n         }\n+        let typeTimeout = null;\n         textBox.addEventListener('keydown',async (e) => {\n+            if(typeTimeout){\n+                clearTimeout(typeTimeout)\n+                typeTimeout = null\n+            }\n+            if(e.target.liveType){\n+                typeTimeout = setTimeout(()=>{\n+                    e.target.lastMessageUid = null\n+                    e.target.value = ''\n+                },3000)\n+            }\n             if(e.key === \"ArrowUp\"){\n                 const value = findDivAboveText(e.target.value).querySelector('.text')\n                 e.target.value = value.textContent\n@@ -102,7 +113,9 @@\n                 \n \t    }else{\n \t\tif(textBox.liveType){\n-            \n+            if(e.target.value.endsWith(\"\\n\") || e.target.value.endsWith(\" \")){\n+                return\n+            } \n             if(e.target.value[0] == \"/\"){\n                 return\n             }\n@@ -116,8 +129,10 @@\n                 return;\n             }\n             app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n+        }else{\n+            app.rpc.set_typing(channelUid)\n         }\n-        app.rpc.set_typing(channelUid)\n+\n         \n \t    }\n         });\n@@ -294,6 +309,10 @@\n     }\n \n     app.addEventListener(\"channel-message\", (data) => {\n+        let display = 'block';\n+        if(!data.text || !data.text.trim()){\n+            display = \"none\";\n+        }\n         if (data.channel_uid !== channelUid) {\n             if(!isMentionForSomeoneElse(data.message)){\n                 channelSidebar.notify(data);\n@@ -316,6 +335,7 @@\n \n         const message = document.createElement(\"div\");\n         message.innerHTML = data.html;\n+        message.style.display = display\n         document.querySelector(\".chat-messages\").appendChild(message.firstChild);\n         updateLayout(doScrollDownBecauseLastMessageIsVisible);\n         setTimeout(() => {\n@@ -373,6 +393,9 @@\n        if(e.target.tagName != 'IMG')\n            return\n       const img = e.target\n+      if(e.target.classList.contains('avatar')){\n+          return\n+      }\n       const overlay = document.createElement('div');\n       overlay.style.position = 'fixed';\n       overlay.style.top = 0;\ndiff --git a/src/snek/view/rpc.py b/src/snek/view/rpc.py\nindex 0469e53..de94633 100644\n--- a/src/snek/view/rpc.py\n+++ b/src/snek/view/rpc.py\n@@ -178,22 +178,28 @@ class RPCView(BaseView):\n             message = await self.services.channel_message.get(message_uid)    \n             if message[\"user_uid\"] != self.user_uid:\n                 raise Exception(\"Not allowed\")\n-            await self.services.socket.broadcast(message[\"channel_uid\"], {\n-                \"channel_uid\": message[\"channel_uid\"],\n-                \"event\": \"update_message_text\",\n-                \"data\": {\n-                     \n-                    \"message_uid\": message_uid,\n-                    \"text\": text\n-                }\n-            })\n-            message[\"message\"] = text\n+            \n+            if message.get_seconds_since_last_update() > 3:\n+                return {\"error\": \"Message too old\",\"seconds_since_last_update\": message.get_seconds_since_last_update(),\"success\": False}\n+\n+            message['message'] = text  \n             if not text:\n                 message['deleted_at'] = now()\n             else:\n                 message['deleted_at'] = None\n+\n             await self.services.channel_message.save(message)\n-            return True\n+            data = message.record \n+            data['text'] = message[\"message\"]\n+            data['message_uid'] = message_uid\n+\n+            await self.services.socket.broadcast(message[\"channel_uid\"], {\n+                \"channel_uid\": message[\"channel_uid\"],\n+                \"event\": \"update_message_text\",\n+                \"data\": message.record\n+            })\n+            \n+            return {\"success\": True}\n \n         async def send_message(self, channel_uid, message):\n             self._require_login()"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added help and online user dialogs", "commit": "dd80f3732b7f500acdd92f6e44f42f9ade0f205b", "diff": "commit dd80f3732b7f500acdd92f6e44f42f9ade0f205b\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 23:16:28 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/base.css b/src/snek/static/base.css\nindex 479d36b..ffe8c1d 100644\n--- a/src/snek/static/base.css\n+++ b/src/snek/static/base.css\n@@ -412,8 +412,8 @@ a {\n             display: block;\n             width: 100%;\n         }\n-        \n-    }\n+    }    \n+    \n     body {\n         justify-content: flex-start;\n@@ -429,3 +429,89 @@ a {\n         position:sticky;\n }\n+\n+dialog {\n+  position: fixed;\n+  top: 50%;\n+  left: 50%;\n+  transform: translate(-50%, -50%);\n+\n+  border: none;\n+  border-radius: 12px;\n+  padding: 24px;\n+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n+  width: 90%;\n+  max-width: 400px;\n+\n+  animation: dialogFadeIn 0.3s ease-out, dialogScaleIn 0.3s ease-out;\n+  z-index: 1000;\n+}\n+\n+dialog::backdrop {\n+  background: rgba(0, 0, 0, 0.7);\n+  backdrop-filter: blur(4px);\n+}\n+\n+dialog .dialog-title {\n+  font-size: 1.5rem;\n+  font-weight: bold;\n+  margin-bottom: 16px;\n+}\n+\n+dialog .dialog-content {\n+  font-size: 1rem;\n+  margin-bottom: 20px;\n+}\n+\n+dialog .dialog-actions {\n+  display: flex;\n+  justify-content: flex-end;\n+  gap: 10px;\n+}\n+\n+dialog .dialog-button {\n+  padding: 8px 16px;\n+  font-size: 0.95rem;\n+  border-radius: 8px;\n+  border: none;\n+  cursor: pointer;\n+  transition: background 0.2s ease;\n+}\n+\n+\n+@keyframes dialogFadeIn {\n+  from { opacity: 0; }\n+  to { opacity: 1; }\n+}\n+\n+@keyframes dialogScaleIn {\n+  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n+  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n+}\n+\n+dialog .dialog-button.primary {\n+  color: white;\n+}\n+\n+dialog .dialog-button.primary:hover {\n+}\n+\n+dialog .dialog-button.secondary {\n+}\n+\n+dialog .dialog-button.secondary:hover {\n+}\n+\n+\ndiff --git a/src/snek/templates/dialog_help.html b/src/snek/templates/dialog_help.html\nnew file mode 100644\nindex 0000000..dae5f81\n--- /dev/null\n+++ b/src/snek/templates/dialog_help.html\n@@ -0,0 +1,61 @@\n+\n+<dialog id=\"help-dialog\">\n+  <div class=\"dialog-backdrop\">\n+    <div class=\"dialog-box\">\n+      <div class=\"dialog-title\"><h2>Help</h2></div>\n+      <div class=\"dialog-content\">\n+        <help-command-list></help-command-list>\n+      </div>\n+      <div class=\"dialog-actions\">\n+        <button class=\"dialog-button primary\">Close</button>\n+      </div>\n+    </div>\n+  </div>\n+</dialog>\n+\n+<script>\n+  class HelpCommandListComponent extends HTMLElement {\n+    helpCommands = [\n+      {\n+        command: \"/help\",\n+        description: \"Show this help message\"\n+      },\n+      {\n+        command: \"/online\",\n+        description: \"Show online users\"\n+      },\n+      {\n+        command: \"/clear\",\n+        description: \"Clear the board\"\n+      },\n+      {\n+        command: \"/img-gen\",\n+        description: \"Generate an image\"\n+      }\n+    ];\n+\n+    constructor() {\n+      super();\n+    }\n+\n+    connectedCallback() {\n+      this.innerHTML = this.helpCommands\n+            .map(cmd => `<div><h2>${cmd.command}</h2><div>${cmd.description}</div></div>`)\n+        .join('');\n+    }\n+  }\n+\n+  customElements.define('help-command-list', HelpCommandListComponent);\n+\n+  const helpDialog = document.getElementById(\"help-dialog\");\n+  const helpCloseButton = helpDialog.querySelector('.dialog-button.primary');\n+    function showHelp() {\n+        helpDialog.showModal();\n+\n+        helpCloseButton.focus();\n+    }\n+    helpCloseButton.addEventListener('click', () => {\n+      helpDialog.close();\n+    });\n+\n+</script>\ndiff --git a/src/snek/templates/dialog_online.html b/src/snek/templates/dialog_online.html\nnew file mode 100644\nindex 0000000..3fc1c08\n--- /dev/null\n+++ b/src/snek/templates/dialog_online.html\n@@ -0,0 +1,28 @@\n+\n+<dialog id=\"online-users\">\n+  <div class=\"dialog-backdrop\">\n+    <div class=\"dialog-box\">\n+      <div class=\"dialog-title\"><h2>Online Users</h2></div>\n+      <div class=\"dialog-content\"><user-list></user-list></div>\n+      <div class=\"dialog-actions\">\n+        <button class=\"dialog-button primary\">Close</button>\n+      </div>\n+    </div>\n+  </div>\n+</dialog>\n+\n+<script>\n+const onlineUsersDialog = document.getElementById(\"online-users\");\n+const closeButton = onlineUsersDialog.querySelector('.dialog-button.primary');\n+\n+closeButton.addEventListener('click', () => {\n+    onlineUsersDialog.close();\n+});\n+\n+async function showOnline() {\n+    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n+    onlineUsersDialog.querySelector('user-list').data = users;\n+    onlineUsersDialog.showModal();\n+    closeButton.focus();\n+}\n+</script>\ndiff --git a/src/snek/templates/online.html b/src/snek/templates/online.html\ndeleted file mode 100644\nindex a241662..0000000\n--- a/src/snek/templates/online.html\n+++ /dev/null\n@@ -1,117 +0,0 @@\n-<style>\n-  position: fixed;\n-  top: 50%;\n-  left: 50%;\n-  transform: translate(-50%, -50%);\n-\n-  border: none;\n-  border-radius: 12px;\n-  padding: 24px;\n-  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.8);\n-  width: 90%;\n-  max-width: 400px;\n-\n-  animation: fadeIn 0.3s ease-out, scaleIn 0.3s ease-out;\n-  z-index: 1000;\n-}\n-\n-  background: rgba(0, 0, 0, 0.7);\n-  backdrop-filter: blur(4px);\n-}\n-\n-  font-size: 1.5rem;\n-  font-weight: bold;\n-  margin-bottom: 16px;\n-}\n-\n-  font-size: 1rem;\n-  margin-bottom: 20px;\n-}\n-\n-  display: flex;\n-  justify-content: flex-end;\n-  gap: 10px;\n-}\n-\n-  padding: 8px 16px;\n-  font-size: 0.95rem;\n-  border-radius: 8px;\n-  border: none;\n-  cursor: pointer;\n-  transition: background 0.2s ease;\n-}\n-\n-  color: white;\n-}\n-\n-}\n-\n-}\n-\n-}\n-\n-@keyframes fadeIn {\n-  from { opacity: 0; }\n-  to { opacity: 1; }\n-}\n-\n-@keyframes scaleIn {\n-  from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }\n-  to { transform: scale(1) translate(-50%, -50%); opacity: 1; }\n-}\n-</style>\n-\n-\n-<dialog id=\"online-users\">\n-    <div class=\"dialog-backdrop\">\n-      <div class=\"dialog-box\">\n-        <div class=\"dialog-title\"><h2>Currently online</h2></div>\n-        <div class=\"dialog-content\"><user-list></user-list></div>\n-        <div class=\"dialog-actions\">\n-          <button class=\"dialog-button primary\">Close</button>\n-        </div>\n-      </div>\n-    </div>\n-  </dialog>\n-\n-<script>\n-const onlineDialog = document.getElementById(\"online-users\");\n-const dialogButton = onlineDialog.querySelector('.dialog-button.primary');\n-\n-dialogButton.addEventListener('click', () => {\n-    onlineDialog.close();\n-});\n-\n-async function showOnlineUsers() {\n-    const users = await app.rpc.getOnlineUsers('{{ channel.uid.value }}');\n-    onlineDialog.querySelector('user-list').data = users;\n-    onlineDialog.showModal();\n-}\n-</script>\n-\n-\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 113b77d..db91188 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -21,7 +21,8 @@\n         <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n     </div>\n </section>\n-{% include \"online.html\" %}\n+{% include \"dialog_help.html\" %}\n+{% include \"dialog_online.html\" %}\n <script type=\"module\">\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n@@ -32,13 +33,16 @@\n     }\n     getInputField().autoComplete = {\n         \"/online\": () =>{\n-            showOnlineUsers();\n+            showOnline();\n         },\n         \"/clear\": () => {\n             document.querySelector(\".chat-messages\").innerHTML = '';\n         },\n         \"/live\": () =>{\n             getInputField().liveType = !getInputField().liveType\n+        },\n+        \"/help\": () => {\n+            showHelp();\n         }\n     }"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added /live command to help dialog", "commit": "79c39828f0a3282f53e3322e19b211b5559466a1", "diff": "commit 79c39828f0a3282f53e3322e19b211b5559466a1\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Thu May 15 23:30:23 2025 +0200\n\n    update.\n\ndiff --git a/src/snek/templates/dialog_help.html b/src/snek/templates/dialog_help.html\nindex dae5f81..eb72662 100644\n--- a/src/snek/templates/dialog_help.html\n+++ b/src/snek/templates/dialog_help.html\n@@ -31,7 +31,11 @@\n       {\n         command: \"/img-gen\",\n         description: \"Generate an image\"\n-      }\n+      },\n+     {\n+        command: \"/live\",\n+         description: \"Toggle live typing mode\"\n+     }\n     ];\n \n     constructor() {"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Implemented user availability service and updated startup sequence", "commit": "c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa", "diff": "commit c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:04:19 2025 +0200\n\n    UPdate.\n\ndiff --git a/src/snek/app.py b/src/snek/app.py\nindex fa211d1..24af03b 100644\n--- a/src/snek/app.py\n+++ b/src/snek/app.py\n@@ -105,12 +105,15 @@ class Application(BaseApplication):\n         self.services = get_services(app=self)\n         self.mappers = get_mappers(app=self)\n         self.broadcast_service = None\n-        self.on_startup.append(self.start_ssh_server)\n+        self.user_availability_service_task = None\n+        \n         self.on_startup.append(self.prepare_asyncio)\n+        self.on_startup.append(self.start_user_availability_service)\n+        self.on_startup.append(self.start_ssh_server)\n         self.on_startup.append(self.prepare_database)\n         \n-\n-\n+    async def start_user_availability_service(self, app):\n+        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())\n     async def snode_sync(self, app):\n         self.sync_service = asyncio.create_task(snode.sync_service(app))\n     \ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex eb40234..ecc7bf9 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -1,7 +1,11 @@\n from snek.model.user import UserModel\n from snek.system.service import BaseService\n from datetime import datetime \n-import json \n+import json\n+import asyncio\n+import logging \n+logger = logging.getLogger(__name__)\n+from snek.system.model import now\n \n class SocketService(BaseService):\n \n@@ -36,10 +40,28 @@ class SocketService(BaseService):\n         self.users = {}\n         self.subscriptions = {}\n         self.last_update = str(datetime.now())\n-    \n+        \n+\n+    async def user_availability_service(self):\n+        logger.info(\"User availability update service started.\")\n+        while True:\n+            logger.info(\"Updating user availability...\")\n+            users_updated = []\n+            for s in self.sockets:\n+                if not s.is_connected:\n+                    continue\n+                if not s.user in users_updated:\n+                    s.user[\"last_ping\"] = now()\n+                    await self.app.services.user.save(s.user)\n+                    users_updated.append(s.user)\n+            logger.info(f\"Updated user availability for {len(users_updated)} online users.\")\n+            await asyncio.sleep(60)\n+\n+\n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n+        logger.info(f\"Added socket for user {s.user['username']}\")\n         if not self.users.get(user_uid):\n             self.users[user_uid] = set()\n         self.users[user_uid].add(s)\n@@ -62,16 +84,21 @@ class SocketService(BaseService):\n         await self._broadcast(channel_uid, message)\n \n     async def _broadcast(self, channel_uid, message):\n+        sent = 0\n         try:\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n                 await self.send_to_user(user_uid, message)\n+                sent += 1\n         except Exception as ex:\n             print(ex, flush=True)\n+        logger.info(f\"Broadcasted a message to {sent} users.\")\n         return True\n \n     async def delete(self, ws):\n         for s in [sock for sock in self.sockets if sock.ws == ws]:\n             await s.close()\n+            logger.info(f\"Removed socket for user {s.user['username']}\")\n             self.sockets.remove(s)\n+"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Update socket service and attachment view\n\n- Update socket service to save last ping\n- Update attachment view to handle multiple attachments\n- Update attachment view to use format from query parameter", "commit": "93462d4c4b93c4f7eb81702801df9159cbb64e8e", "diff": "commit 93462d4c4b93c4f7eb81702801df9159cbb64e8e\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:32:54 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/socket.py b/src/snek/service/socket.py\nindex ecc7bf9..d0ff024 100644\n--- a/src/snek/service/socket.py\n+++ b/src/snek/service/socket.py\n@@ -61,6 +61,8 @@ class SocketService(BaseService):\n     async def add(self, ws, user_uid):\n         s = self.Socket(ws, await self.app.services.user.get(uid=user_uid))\n         self.sockets.add(s)\n+        s.user[\"last_ping\"] = now()\n+        await self.app.services.user.save(s.user)\n         logger.info(f\"Added socket for user {s.user['username']}\")\n         if not self.users.get(user_uid):\n             self.users[user_uid] = set()\n@@ -89,8 +91,7 @@ class SocketService(BaseService):\n             async for user_uid in self.services.channel_member.get_user_uids(\n                 channel_uid\n             ):\n-                await self.send_to_user(user_uid, message)\n-                sent += 1\n+                sent += await self.send_to_user(user_uid, message)\n         except Exception as ex:\n             print(ex, flush=True)\n         logger.info(f\"Broadcasted a message to {sent} users.\")\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex db91188..02e60df 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -144,9 +144,11 @@\n             getInputField().focus();\n         })\n         document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n+            let message = \"\"\n             e.detail.files.forEach((file)=>{\n-                app.rpc.sendMessage(channelUid,`[${file.name}](/channel/attachment/${file.relative_url})`)\n+                message += `[${file.name}](/channel/attachment/${file.relative_url})`\n             })\n+            app.rpc.sendMessage(channelUid,message)\n         })\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\ndiff --git a/src/snek/view/channel.py b/src/snek/view/channel.py\nindex 9d36d8f..7580642 100644\n--- a/src/snek/view/channel.py\n+++ b/src/snek/view/channel.py\n@@ -17,20 +17,21 @@ class ChannelAttachmentView(BaseView):\n             relative_url=relative_path\n         )\n \n-        current_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n-\n-        format = self.request.query.get(\"format\")\n+        original_format = mimetypes.guess_type(channel_attachment[\"path\"])[0]\n+        format_ = self.request.query.get(\"format\")\n         width = self.request.query.get(\"width\")\n         height = self.request.query.get(\"height\")\n \n-        if any([format, width, height]) and current_format.startswith(\"image/\"):\n+        if any([format_, width, height]) and original_format.startswith(\"image/\"):\n+            if not format_:\n+                format_ = original_format.split(\"/\")[1]\n             with Image.open(channel_attachment[\"path\"]) as image:\n                 response = web.StreamResponse(\n                     status=200,\n                     reason=\"OK\",\n                     headers={\n                         \"Cache-Control\": f\"public, max-age={1337 * 420}\",\n-                        \"Content-Type\": f\"image/{format}\" if format else current_format,\n+                        \"Content-Type\": f\"image/{format_}\",\n                         \"Content-Disposition\": f'attachment; filename=\"{channel_attachment[\"name\"]}\"',\n                     },\n                 )\n@@ -65,19 +66,21 @@ class ChannelAttachmentView(BaseView):\n                         )\n \n                 await response.prepare(self.request)\n-\n                 naughty_steal = response.write\n-                loop = asyncio.get_event_loop()\n-\n+                tasks = []\n                 def sync_writer(*args, **kwargs):\n-                    return loop.run_until_complete(naughty_steal(*args, **kwargs))\n-\n+                    tasks.append(naughty_steal(*args, **kwargs))\n+                    return True\n+                \n                 setattr(response, \"write\", sync_writer)\n \n-                image.save(response, format=self.request.query[\"format\"])\n+                image.save(response, format=format_)\n \n                 setattr(response, \"write\", naughty_steal)\n+                \n+                await asyncio.gather(*tasks)\n+                \n                 return response\n         else:\n             response = web.FileResponse(channel_attachment[\"path\"])"}
{"repo": ".", "date": "2025-05-16", "line": "fix: Increased last ping cooldown time", "commit": "c387225a6e8aa826b944ff3c53c4045db25db758", "diff": "commit c387225a6e8aa826b944ff3c53c4045db25db758\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 00:41:40 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/service/channel.py b/src/snek/service/channel.py\nindex f2288cb..ecc9519 100644\n--- a/src/snek/service/channel.py\n+++ b/src/snek/service/channel.py\n@@ -86,7 +86,7 @@ class ChannelService(BaseService):\n             if (\n                 datetime.fromisoformat(now())\n                 - datetime.fromisoformat(user[\"last_ping\"])\n-            ).total_seconds() < 20:\n+            ).total_seconds() < 180:\n                 yield user\n \n     async def get_for_user(self, user_uid):"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Added cache enable/disable functionality", "commit": "00557ec9eaab7256d5c129fc8c00c12650ea3fd3", "diff": "commit 00557ec9eaab7256d5c129fc8c00c12650ea3fd3\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Fri May 16 01:38:42 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/system/cache.py b/src/snek/system/cache.py\nindex eed888a..8f8cdc3 100644\n--- a/src/snek/system/cache.py\n+++ b/src/snek/system/cache.py\n@@ -14,10 +14,13 @@ class Cache:\n         self.cache = {}\n         self.max_items = max_items\n         self.stats = {}\n+        self.enabled = False\n         self.lru = []\n         self.version = ((42 + 420 + 1984 + 1990 + 10 + 6 + 71 + 3004 + 7245) ^ 1337) + 4\n \n     async def get(self, args):\n+        if not self.enabled:\n+            return None\n         await self.update_stat(args, \"get\")\n         try:\n             self.lru.pop(self.lru.index(args))\n@@ -76,6 +79,8 @@ class Cache:\n         )\n \n     async def set(self, args, result):\n+        if not self.enabled:\n+            return\n         is_new = args not in self.cache\n         self.cache[args] = result\n         await self.update_stat(args, \"set\")\n@@ -94,6 +99,8 @@ class Cache:\n \n     async def delete(self, args):\n+        if not self.enabled:\n+            return\n         await self.update_stat(args, \"delete\")\n         if args in self.cache:\n             try:"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor index.html with improved styling and features overview", "commit": "c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7", "diff": "commit c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 00:53:27 2025 +0200\n\n    t:\n\ndiff --git a/src/snek/templates/index.html b/src/snek/templates/index.html\nindex a1e8894..e12fe2b 100644\n--- a/src/snek/templates/index.html\n+++ b/src/snek/templates/index.html\n@@ -1,31 +1,288 @@\n-<!DOCTYPE html>\n+\n+                        <!DOCTYPE html>\n+                        <html lang=\"en\">\n+                        <head>\n+                            <meta charset=\"UTF-8\">\n+                            <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n+\t\t\t\t\t\t\t<style>\n+\t\t\t\t\t\t\t\tbody {\n+\t\t\t\t\t\t\t\t}\n+\n+\t\t\t\t\t\t\t\t\n+    * { margin:0; padding:0; box-sizing:border-box; }\n+    body {\n+      font-family: 'Segoe UI',sans-serif;\n+      line-height:1.5;\n+    }\n+    a:hover { text-decoration: underline; }\n+\n+    .container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }\n+\n+    .hero {\n+      text-align: center;\n+      padding: 4rem 0;\n+    }\n+    .hero h1 {\n+      font-size: 3rem;\n+      -webkit-background-clip: text;\n+      color: transparent;\n+    }\n+    .hero p {\n+      font-size: 1.2rem;\n+      margin: 1rem 0 2rem;\n+    }\n+    .btn {\n+      display: inline-block;\n+      padding: .75rem 1.5rem;\n+      margin: .5rem;\n+      font-weight: bold;\n+      border-radius: 4px;\n+      transition: background .2s;\n+    }\n+\n+    .grid {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n+      gap: 1.5rem;\n+      margin-top: 2rem;\n+    }\n+    .card {\n+      border-radius: 6px;\n+      padding: 1.5rem;\n+      box-shadow: 0 2px 6px rgba(0,0,0,0.6);\n+    }\n+    .card h3 {\n+      margin-bottom: .75rem;\n+    }\n+    .card ul {\n+      list-style: disc inside;\n+      margin-top: .5rem;\n+    }\n+\n+    footer {\n+      text-align: center;\n+      font-size: .9rem;\n+      padding: 2rem 0;\n+    }\n+    footer code {\n+      padding: 2px 4px;\n+      border-radius: 3px;\n+    }\n+\n+    @media (max-width: 480px) {\n+      .hero h1 { font-size: 2.4rem; }\n+      .btn { width: 100%; box-sizing: border-box; text-align:center; }\n+    }\n+  \n+\n+\t\t\t\t\t\t\t</style>\n+                        </head>\n+                        <body>\n+                            <!DOCTYPE html>\n <html lang=\"en\">\n <head>\n-  <meta charset=\"UTF-8\">\n-  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n-  <title>Snek chat by Molodetz</title>\n-    <link rel=\"stylesheet\" href=\"generic-form.css\">\n-    <link rel=\"stylesheet\" href=\"base.css\">\n-<style>\n-  .registration-container {\n-        max-width: 300px;\n-        margin: 20px auto;\n-        padding: 20px;\n-  }\n-</style>\n-    <script src=\"/fancy-button.js\"></script>\n+  <meta charset=\"UTF-8\" />\n+  <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n+  <title>Snek \u2013 The Ultimate Web Community</title>\n+  <style>\n+    * { margin:0; padding:0; box-sizing:border-box; }\n+    body {\n+      font-family: 'Segoe UI',sans-serif;\n+      line-height:1.5;\n+    }\n+    a:hover { text-decoration: underline; }\n+\n+    .container { width: 90%; max-width: 960px; margin: auto; padding: 2rem 0; }\n+\n+    .hero {\n+      text-align: center;\n+      padding: 4rem 0;\n+    }\n+    .hero h1 {\n+      font-size: 3rem;\n+      -webkit-background-clip: text;\n+      color: transparent;\n+    }\n+    .hero p {\n+      font-size: 1.2rem;\n+      margin: 1rem 0 2rem;\n+    }\n+    .btn {\n+      display: inline-block;\n+      padding: .75rem 1.5rem;\n+      margin: .5rem;\n+      font-weight: bold;\n+      border-radius: 4px;\n+      transition: background .2s;\n+    }\n+\n+    .grid {\n+      display: grid;\n+      grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n+      gap: 1.5rem;\n+      margin-top: 2rem;\n+    }\n+    .card {\n+      border-radius: 6px;\n+      padding: 1.5rem;\n+      box-shadow: 0 2px 6px rgba(0,0,0,0.6);\n+    }\n+    .card h3 {\n+      margin-bottom: .75rem;\n+    }\n+    .card ul {\n+      list-style: disc inside;\n+      margin-top: .5rem;\n+    }\n+\n+    footer {\n+      text-align: center;\n+      font-size: .9rem;\n+      padding: 2rem 0;\n+    }\n+    footer code {\n+      padding: 2px 4px;\n+      border-radius: 3px;\n+    }\n+\n+    @media (max-width: 480px) {\n+      .hero h1 { font-size: 2.4rem; }\n+      .btn { width: 100%; box-sizing: border-box; text-align:center; }\n+    }\n+  </style>\n </head>\n <body>\n-  <div class=\"registration-container\">\n+\n+  <header class=\"container hero\">\n     <h1>Snek</h1>\n-    <p style=\"padding-bottom:20px\">Rocket Chat got bloated, too commercialized,\n-    So Snek came through, lean and optimized.</p>\n-    <div style=\"text-align: center;\">\n-    <fancy-button url=\"/login.html\" text=\"Login\"></fancy-button>\n-    <span style=\"padding:10px;\">OR</span>\n-    <fancy-button url=\"/register.html\" text=\"Register\"></fancy-button>\n-    </div>\n-  </div>\n+    <p>The Ultimate Web Community for Devs, Testers &amp; AI Enthusiasts</p>\n+    <a href=\"/login.html\" class=\"btn\">Login</a>\n+    <a href=\"/register.html\" class=\"btn\">Register</a>\n+  </header>\n+\n+  <main class=\"container\">\n+\n+    <section id=\"features\" class=\"grid\">\n+      <div class=\"card\">\n+        <h3>File Sharing</h3>\n+        <ul>\n+          <li>SFTP storage with your Snek credentials</li>\n+          <li>WebDAV support \u2013 same login</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Git Repositories</h3>\n+        <ul>\n+          <li>Configure repos for any official Git client</li>\n+          <li>Instant setup, push &amp; pull</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>AI Powerhouse</h3>\n+        <ul>\n+          <li>Chat with free &amp; commercial AIs</li>\n+          <li>Generate AI-powered images</li>\n+          <li>Build your own AI bots in &lt;5 minutes (copy/paste example)</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Dev &amp; Terminal</h3>\n+        <ul>\n+          <li>Ubuntu web terminal in-browser</li>\n+          <li>Full profile &amp; permissions management</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Chat &amp; Media</h3>\n+        <ul>\n+          <li>Upload any file type in chat</li>\n+          <li>Rich media support (audio, video, images\u2026)</li>\n+          <li>Direct messaging with other users</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Privacy &amp; Community</h3>\n+        <ul>\n+          <li>No logging\u2014never even your IP</li>\n+          <li>No email required to sign up</li>\n+          <li>Multi-national, open community</li>\n+          <li>Hacking encouraged!</li>\n+        </ul>\n+      </div>\n+\n+      <div class=\"card\">\n+        <h3>Customization &amp; Deployment</h3>\n+        <ul>\n+          <li>Full layout &amp; theme customization</li>\n+          <li>Install as a PWA on your phone</li>\n+          <li>Optionally self-host: <code>pip install snek</code>, zero config</li>\n+        </ul>\n+      </div>\n+    </section>\n+\n+    <section id=\"signup\" style=\"text-align:center; margin:4rem 0;\">\n+      <h2>Ready to join?</h2>\n+      <p>No email. No logs. Just sign up, pick a username, and dive in!</p>\n+      <a href=\"/register\" class=\"btn\">Sign Up Now</a>\n+    </section>\n+\n+    <section id=\"selfhost\" style=\"text-align:center; margin-bottom:4rem;\">\n+      <h2>Self-Host in Seconds</h2>\n+      <p>Just run:</p>\n+snek serve\n+      </pre>\n+      <p>No configuration required\u2014it's that simple.</p>\n+    </section>\n+\n+  </main>\n+\n+  <footer>\n+    <p>&copy; 2025 Snek \u2013 Join our global community of developers, testers &amp; AI enthusiasts.</p>\n+  </footer>\n+\n </body>\n </html>"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor chat input component with improved auto-completion and live typing functionality", "commit": "48c3daf3983e3b6e04a0c5888febceb69db9d661", "diff": "commit 48c3daf3983e3b6e04a0c5888febceb69db9d661\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 00:54:15 2025 +0200\n\n    Update.\n\ndiff --git a/src/snek/static/chat-input.js b/src/snek/static/chat-input.js\nindex c1d767d..2d0914e 100644\n--- a/src/snek/static/chat-input.js\n+++ b/src/snek/static/chat-input.js\n@@ -1,69 +1,234 @@\n-\n-\n-\n-\n-class ChatInputElement extends HTMLElement {\n-  _chatWindow = null \n-  constructor() {\n-    super();\n-    this.attachShadow({ mode: 'open' });\n-    this.component = document.createElement('div');\n-    this.shadowRoot.appendChild(this.component);\n-  }\n-  set chatWindow(value){\n-    this._chatWindow = value \n-\n-  }\n-  get chatWindow(){\n-    return this._chatWindow \n-  }\n-  get channelUid() {\n-    return this.chatWindow.channel.uid\n-  }\n-  connectedCallback() {\n-    const link = document.createElement('link');\n-    link.rel = 'stylesheet';\n-    link.href = '/base.css';\n-    this.component.appendChild(link);\n-\n-    this.container = document.createElement('div');\n-    this.container.classList.add('chat-input');\n-    this.container.innerHTML = `\n-      <textarea placeholder=\"Type a message...\" rows=\"2\"></textarea>\n-      <upload-button></upload-button>\n-    `;\n-    this.textBox = this.container.querySelector('textarea');\n-    this.uploadButton = this.container.querySelector('upload-button');\n-    this.uploadButton.chatInput = this \n-    this.textBox.addEventListener('input', (e) => {\n-      this.dispatchEvent(new CustomEvent('input', { detail: e.target.value, bubbles: true }));\n-      const message = e.target.value;\n-      const button = this.container.querySelector('button');\n-      button.disabled = !message;\n-    });\n-\n-    this.textBox.addEventListener('change', (e) => {\n-      e.preventDefault();\n-      this.dispatchEvent(new CustomEvent('change', { detail: e.target.value, bubbles: true }));\n-      console.error(e.target.value);\n-    });\n-\n-    this.textBox.addEventListener('keydown', (e) => {\n-      if (e.key === 'Enter' && !e.shiftKey) {\n-        e.preventDefault();\n-        const message = e.target.value.trim();\n-        if (!message) return;\n-        this.dispatchEvent(new CustomEvent('submit', { detail: message, bubbles: true }));\n-        e.target.value = '';\n-      }\n-    });\n-\n-    this.component.appendChild(this.container);\n-  }\n+\n+import { app } from '../app.js';\n+\n+class ChatInputComponent extends HTMLElement {\n+    autoCompletions = {\n+        'example 1': () => {\n+\n+        },\n+        'example 2': () => {\n+\n+        }\n+    }\n+\n+    constructor() {\n+        super();\n+        this.lastUpdateEvent = new Date();\n+        this.textarea = document.createElement(\"textarea\");\n+        this._value = \"\";\n+        this.value = this.getAttribute(\"value\") || \"\";\n+        this.previousValue = this.value;\n+        this.lastChange = new Date();\n+        this.changed = false;\n+    }\n+\n+    get value() {\n+        return this._value;\n+    }\n+\n+    set value(value) {\n+        this._value = value || \"\";\n+        this.textarea.value = this._value;\n+    }\n+\n+    resolveAutoComplete() {\n+        let count = 0;\n+        let value = null;\n+        Object.keys(this.autoCompletions).forEach((key) => {\n+            if (key.startsWith(this.value)) {\n+                count++;\n+                value = key;\n+            }\n+        });\n+        if (count == 1)\n+            return value;\n+        return null;\n+    }\n+\n+    isActive() {\n+        return document.activeElement === this.textarea;\n+    }\n+\n+    focus() {\n+        this.textarea.focus();\n+    }\n+\n+    connectedCallback() {\n+        this.liveType = this.getAttribute(\"live-type\") === \"true\";\n+        this.liveTypeInterval = parseInt(this.getAttribute(\"live-type-interval\")) || 3;\n+        this.channelUid = this.getAttribute(\"channel\");\n+        this.messageUid = null;\n+\n+        this.classList.add(\"chat-input\");\n+\n+        this.textarea.setAttribute(\"placeholder\", \"Type a message...\");\n+        this.textarea.setAttribute(\"rows\", \"2\");\n+\n+        this.appendChild(this.textarea);\n+\n+        this.uploadButton = document.createElement(\"upload-button\");\n+        this.uploadButton.setAttribute(\"channel\", this.channelUid);\n+        this.uploadButton.addEventListener(\"upload\", (e) => {\n+            this.dispatchEvent(new CustomEvent(\"upload\", e));\n+        });\n+        this.uploadButton.addEventListener(\"uploaded\", (e) => {\n+            this.dispatchEvent(new CustomEvent(\"uploaded\", e));\n+        });\n+\n+        this.appendChild(this.uploadButton);\n+\n+        this.textarea.addEventListener(\"keyup\", (e) => {\n+            if(e.key === 'Enter' && !e.shiftKey) {\n+                this.value = ''\n+                e.target.value = '';\n+                return \n+            }\n+            this.value = e.target.value;\n+            this.changed = true;\n+            this.update();\n+        });\n+\n+        this.textarea.addEventListener(\"keydown\", (e) => {\n+            this.value = e.target.value;\n+            if (e.key === \"Tab\") {\n+                e.preventDefault();\n+                let autoCompletion = this.resolveAutoComplete();\n+                if (autoCompletion) {\n+                    e.target.value = autoCompletion;\n+                    this.value = autoCompletion;\n+                    return;\n+                }\n+            }\n+            if (e.key === 'Enter' && !e.shiftKey) {\n+                e.preventDefault();\n+\n+                const message = e.target.value;\n+                this.messageUid = null;\n+                this.value = '';\n+                this.previousValue = '';\n+\n+                if (!message) {\n+                    return;\n+                }\n+\n+                let autoCompletion = this.autoCompletions[message];\n+                if (autoCompletion) {\n+                    this.value = '';\n+                    this.previousValue = '';\n+                    e.target.value = '';\n+                    autoCompletion();\n+                    return;\n+                }\n+\n+                e.target.value = '';\n+                this.value = '';\n+                this.messageUid = null;\n+                this.sendMessage(this.channelUid, message).then((uid) => {\n+                    this.messageUid = uid;\n+                });\n+            }\n+        });\n+\n+        this.changeInterval = setInterval(() => {\n+            if (!this.liveType) {\n+                return;\n+            }\n+            if (this.value !== this.previousValue) {\n+                if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) {\n+                    this.value = '';\n+                    this.previousValue = '';\n+                }\n+                this.lastChange = new Date();\n+            }\n+            this.update();\n+        }, 300);\n+\n+        this.addEventListener(\"upload\", (e) => {\n+            this.focus();\n+        });\n+        this.addEventListener(\"uploaded\", function (e) {\n+            let message = \"\";\n+            e.detail.files.forEach((file) => {\n+                message += `[${file.name}](/channel/attachment/${file.relative_url})`;\n+            });\n+            app.rpc.sendMessage(this.channelUid, message);\n+        });\n+    }\n+\n+    trackSecondsBetweenEvents(event1Time, event2Time) {\n+        const millisecondsDifference = event2Time.getTime() - event1Time.getTime();\n+        return millisecondsDifference / 1000;\n+    }\n+\n+    newMessage() {\n+        if (!this.messageUid) {\n+            this.messageUid = '?';\n+        }\n+\n+        this.sendMessage(this.channelUid, this.value).then((uid) => {\n+            this.messageUid = uid;\n+        });\n+    }\n+\n+    updateMessage() {\n+        if (this.value[0] == \"/\") {\n+            return false;\n+        }\n+        if (!this.messageUid) {\n+            this.newMessage();\n+            return false;\n+        }\n+        if (this.messageUid === '?') {\n+            return false;\n+        }\n+        if (typeof app !== \"undefined\" && app.rpc && typeof app.rpc.updateMessageText === \"function\") {\n+            app.rpc.updateMessageText(this.messageUid, this.value);\n+        }\n+    }\n+\n+    updateStatus() {\n+        if (this.liveType) {\n+            return;\n+        }\n+        if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {\n+            this.lastUpdateEvent = new Date();\n+            if (typeof app !== \"undefined\" && app.rpc && typeof app.rpc.set_typing === \"function\") {\n+                app.rpc.set_typing(this.channelUid);\n+            }\n+        }\n+    }\n+\n+    update() {\n+        const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval;\n+        const changed = (this.value !== this.previousValue);\n+\n+        if (changed || expired) {\n+            this.lastChange = new Date();\n+            this.updateStatus();\n+        }\n+\n+        this.previousValue = this.value;\n+\n+        if (this.liveType && expired) {\n+            this.value = \"\";\n+            this.previousValue = \"\";\n+            this.messageUid = null;\n+            return;\n+        }\n+\n+        if (changed) {\n+            if (this.liveType) {\n+                this.updateMessage();\n+            }\n+        }\n+    }\n+\n+    async sendMessage(channelUid, value) {\n+        if (!value.trim()) {\n+            return null;\n+        }\n+        return await app.rpc.sendMessage(channelUid, value);\n+    }\n }\n \n-customElements.define('chat-input', ChatInputElement);\n+customElements.define('chat-input', ChatInputComponent);\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 7baa67a..596b5d1 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -18,6 +18,7 @@\n   <script src=\"/file-manager.js\" type=\"module\"></script>\n   <script src=\"/user-list.js\"></script>\n   <script src=\"/message-list.js\" type=\"module\"></script>\n+  <script src=\"/chat-input.js\" type=\"module\"></script>\n   <link rel=\"stylesheet\" href=\"/user-list.css\">\n \n   <link rel=\"stylesheet\" href=\"/base.css\">\ndiff --git a/src/snek/templates/web.html b/src/snek/templates/web.html\nindex 02e60df..94d0ac5 100644\n--- a/src/snek/templates/web.html\n+++ b/src/snek/templates/web.html\n@@ -4,10 +4,6 @@\n \n {% block main %}\n \n-\n-\n-\n-\n <section class=\"chat-area\">\n     <message-list class=\"chat-messages\">\n     {% for message in messages %}\n@@ -16,10 +12,7 @@\n         {% endautoescape %}\n     {% endfor %}\n     </message-list>\n-    <div class=\"chat-input\">\n-        <textarea list=\"chat-input-autocomplete-items\" placeholder=\"Type a message...\" rows=\"2\" autocomplete=\"on\"></textarea>\n-        <upload-button channel=\"{{ channel.uid.value }}\"></upload-button>\n-    </div>\n+    <chat-input live-type=\"false\" channel=\"{{ channel.uid.value }}\"></chat-input>\n </section>\n {% include \"dialog_help.html\" %}\n {% include \"dialog_online.html\" %}\n@@ -27,11 +20,8 @@\n     import { app } from \"/app.js\";\n     import { Schedule } from \"/schedule.js\";\n     const channelUid = \"{{ channel.uid.value }}\";\n-\n-    function getInputField(){\n-        return document.querySelector(\"textarea\")\n-    }\n-    getInputField().autoComplete = {\n+    const chatInputField = document.querySelector(\"chat-input\");\n+    chatInputField.autoCompletions = {\n         \"/online\": () =>{\n             showOnline();\n         },\n@@ -39,117 +29,15 @@\n             document.querySelector(\".chat-messages\").innerHTML = '';\n         },\n         \"/live\": () =>{\n-            getInputField().liveType = !getInputField().liveType\n+            \n+            chatInputField.liveType = !chatInputField.liveType\n         },\n         \"/help\": () => {\n             showHelp();\n         }\n-    }\n-\n-\n-    function initInputField(textBox) {\n-        if(textBox.liveType == undefined){\n-            textBox.liveType = false\n-        }\n-        let typeTimeout = null;\n-        textBox.addEventListener('keydown',async (e) => {\n-            if(typeTimeout){\n-                clearTimeout(typeTimeout)\n-                typeTimeout = null\n-            }\n-            if(e.target.liveType){\n-                typeTimeout = setTimeout(()=>{\n-                    e.target.lastMessageUid = null\n-                    e.target.value = ''\n-                },3000)\n-            }\n-            if(e.key === \"ArrowUp\"){\n-                const value = findDivAboveText(e.target.value).querySelector('.text')\n-                e.target.value = value.textContent\n-                console.info(\"HIERR\")\n-                return\n-            }\n-            if (e.key === \"Tab\") {\n-\n-                const message = e.target.value.trim();\n-                if (!message) {\n-                    return\n-                }\n-                let autoCompleteHandler = null;\n-                Object.keys(e.target.autoComplete).forEach((key)=>{\n-                    if(key.startsWith(message)){\n-                        if(autoCompleteHandler){\n-                            return \n-                        }\n-                        autoCompleteHandler = key\n-                    }\n-                })\n-                if(autoCompleteHandler){\n-                    e.preventDefault();\n-                    e.target.value = autoCompleteHandler;\n-                    return\n-                }\n-            }\n-            if (e.key === 'Enter' && !e.shiftKey) {\n-                e.preventDefault();\n-                const message = e.target.value.trim();\n-                if (!message) {\n-                    return\n-                }\n-                let autoCompleteHandler = e.target.autoComplete[message]\n-                if(autoCompleteHandler){\n-                    const value = message;\n-                    e.target.value = '';\n-                    autoCompleteHandler(value)\n-                    return\n-                }\n-\n-                e.target.value = '';\n-                if(textBox.liveType && textBox.lastMessageUid && textBox.lastMessageUid != '?'){\n-                        \n-                \n-                    app.rpc.updateMessageText(textBox.lastMessageUid, message)\n-                textBox.lastMessageUid = null\n-                    return \n-                }\n-\n-                const messageResponse = await app.rpc.sendMessage(channelUid, message);\n-                \n-\t    }else{\n-\t\tif(textBox.liveType){\n-            if(e.target.value.endsWith(\"\\n\") || e.target.value.endsWith(\" \")){\n-                return\n-            } \n-            if(e.target.value[0] == \"/\"){\n-                return\n-            }\n-            if(!textBox.lastMessageUid){\n-                textBox.lastMessageUid = '?'\n-                app.rpc.sendMessage(channelUid, e.target.value).then((messageResponse)=>{\n-                    textBox.lastMessageUid = messageResponse\n-                })\n-            }\n-            if(textBox.lastMessageUid == '?'){\n-                return;\n-            }\n-            app.rpc.updateMessageText(textBox.lastMessageUid, e.target.value)\n-        }else{\n-            app.rpc.set_typing(channelUid)\n-        }\n-\n-        \n-\t    }\n-        });\n-        document.querySelector(\"upload-button\").addEventListener(\"upload\",function(e){\n-            getInputField().focus();\n-        })\n-        document.querySelector(\"upload-button\").addEventListener(\"uploaded\",function(e){\n-            let message = \"\"\n-            e.detail.files.forEach((file)=>{\n-                message += `[${file.name}](/channel/attachment/${file.relative_url})`\n-            })\n-            app.rpc.sendMessage(channelUid,message)\n-        })\n+     }\n+    \n+        const textBox = document.querySelector(\"chat-input\").textarea\n         textBox.addEventListener(\"paste\", async (e) => {\n             try {\n                 const clipboardItems = await navigator.clipboard.read();\n@@ -168,7 +56,7 @@\n                 }\n \n                 if (dt.items.length > 0) {\n-                    const uploadButton = document.querySelector(\"upload-button\");\n+                    const uploadButton = chatInputField.uploadButton\n                     const input = uploadButton.shadowRoot.querySelector('.file-input')\n                     input.files = dt.files;\n \n@@ -187,7 +75,7 @@\n \n             const dt = e.dataTransfer;\n             if (dt.items.length > 0) {\n-                const uploadButton = document.querySelector(\"upload-button\");\n+                const uploadButton = chatInputField.uploadButton\n                 const input = uploadButton.shadowRoot.querySelector('.file-input')\n                 input.files = dt.files;\n \n@@ -197,13 +85,16 @@\n         chatInput.addEventListener(\"dragover\", async (e) => {\n             e.preventDefault();\n             e.dataTransfer.dropEffect = \"link\";\n+        \n+\n         })\n \n-        textBox.focus();\n-    }\n+            chatInputField.textarea.focus();\n+\n+    \n \n     function replyMessage(message) {\n-        const field = getInputField()\n+        const field = chatInputField\n         field.value = \"```markdown\\n> \" + (message || '') + \"\\n```\\n\";\n         field.focus();\n     }\n@@ -294,8 +185,8 @@\n         lastMessage = messagesContainer.querySelector(\".message:last-child\");\n         if (doScrollDown) {\n             lastMessage?.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n-             const inputBox = document.querySelector(\".chat-input\");\n-             inputBox.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n+             \n+             chatInputField.scrollIntoView({ block: \"end\", inline: \"nearest\" });\n         }\n     }\n \n@@ -378,17 +269,17 @@\n             messagesContainer.querySelector(\".message:last-child\").scrollIntoView({ block: \"end\", inline: \"nearest\" });\n                 setTimeout(() => {\n                     \n-                    getInputField().focus();\n+                    chatInputField.focus();\n                 },500)\n \n             }\n         }\n         if (event.shiftKey && event.key === 'G') {\n-            if(document.activeElement != getInputField()){\n+            if(chatInputField.isActive()){\n             \n                 updateLayout(true);\n                 setTimeout(() => {\n-                    getInputField().focus();\n+                    chatInputField.focus();\n                 },500)\n             }\n \n@@ -432,7 +323,6 @@\n         document.body.removeChild(overlay);\n       });\n     });\n-    initInputField(getInputField());\n     updateLayout(true);\n </script>\n {% endblock %}"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Added star animation to sandbox page", "commit": "e79abf4a26454cddf766cd1ba138554817c820cb", "diff": "commit e79abf4a26454cddf766cd1ba138554817c820cb\nAuthor: retoor <retoor@molodetz.nl>\nDate:   Sat May 17 17:46:59 2025 +0200\n\n    Update stars.\n\ndiff --git a/src/snek/static/sandbox.css b/src/snek/static/sandbox.css\nnew file mode 100644\nindex 0000000..1419fe4\n--- /dev/null\n+++ b/src/snek/static/sandbox.css\n@@ -0,0 +1,28 @@\n+    .star {\n+      position: absolute;\n+      width: 2px;\n+      height: 2px;\n+      border-radius: 50%;\n+      opacity: 0;\n+      animation: twinkle ease-in-out infinite;\n+    }\n+\n+    @keyframes twinkle {\n+      0%, 100% { opacity: 0; }\n+      50%      { opacity: 1; }\n+    }\n+\n+    .content {\n+      position: relative;\n+      z-index: 1;\n+      font-family: sans-serif;\n+      text-align: center;\n+      top: 40%;\n+      transform: translateY(-40%);\n+    }\n+\ndiff --git a/src/snek/templates/app.html b/src/snek/templates/app.html\nindex 596b5d1..ceef196 100644\n--- a/src/snek/templates/app.html\n+++ b/src/snek/templates/app.html\n@@ -19,6 +19,7 @@\n   <script src=\"/user-list.js\"></script>\n   <script src=\"/message-list.js\" type=\"module\"></script>\n   <script src=\"/chat-input.js\" type=\"module\"></script>\n+  <link rel=\"stylesheet\" href=\"/sandbox.css\">\n   <link rel=\"stylesheet\" href=\"/user-list.css\">\n \n   <link rel=\"stylesheet\" href=\"/base.css\">\n@@ -78,5 +79,6 @@ let installPrompt = null\n     \n         ;\n     </script>\n+    {% include \"sandbox.html\" %}\n </body>\n </html>\ndiff --git a/src/snek/templates/sandbox.html b/src/snek/templates/sandbox.html\nnew file mode 100644\nindex 0000000..6fd9b3f\n--- /dev/null\n+++ b/src/snek/templates/sandbox.html\n@@ -0,0 +1,31 @@\n+\n+\t\t\t\t\t\t\t<script>\n+                            \t\n+    const STAR_COUNT = 200;\n+    const body = document.body;\n+\n+    for (let i = 0; i < STAR_COUNT; i++) {\n+      const star = document.createElement('div');\n+      star.classList.add('star');\n+\n+      star.style.left  = Math.random() * 100 + '%';\n+      star.style.top   = Math.random() * 100 + '%';\n+\n+      star.style.width  = size + 'px';\n+      star.style.height = size + 'px';\n+\n+      star.style.animationDuration = duration + 's';\n+      star.style.animationDelay    = delay + 's';\n+\n+      body.appendChild(star);\n+    }\n+  \n+\n+\t\t\t\t\t\t\t</script>"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic structure and boilerplate files", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with basic description and setup instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added basic login and register pages with styling.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0"}
{"repo": ".", "date": "2025-01-18", "line": "docs(docker): Add restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and introduce generic form components.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Complete system with basic functionality and initial views", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button navigation", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated button URLs in index.html", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and layout improvements for responsive design", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved aesthetics and responsiveness", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor login and register form endpoints to use /login.json and /register.json", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Switch to gunicorn and add docs and about pages", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added API documentation with code examples and HTML template", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog element", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field.", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Add documentation subapp and markdown extension", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation and basic API examples", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor session management and improve status endpoint", "commit": "12ca8e4296ca9693276422e524d7061685556ba0"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and mapper setup, introduce Cache and Object\n\nThis commit refactors the service and mapper setup to utilize a more structured approach with SimpleNamespace and Object. It also introduces a Cache class for caching frequently accessed data and a BaseObject class for managing object attributes. Additionally, new mappers and models for channels, channel members, and channel messages have been added to support the new features.", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor model and service imports for clarity", "commit": "f25feeeca3502eee94554e7152ca7ca946115053"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and WebSocket support for real-time communication.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df"}
{"repo": ".", "date": "2025-01-26", "line": "refactor: Minor code formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Use dynamic websocket URL based on environment", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improve WebSocket connection handling and UI\n\nThis commit introduces several improvements:\n\n-   Added error handling to WebSocket send operations in `SocketService`.\n-   Updated the WebSocket URL in `app.js` to include the port number.\n-   Implemented `query` methods in `BaseMapper` and `BaseService` for database queries.\n-   Added a `chat-window` component and updated the HTML structure for a better chat interface.\n-   Implemented `get_messages` method in `RPCView` to fetch messages from the database.\n-   Added `get_channels` method in `RPCView` to fetch channels.", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and configured database path", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added notification service and related mappings and services.", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Implement chat input component with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove unnecessary console statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Removed console logs and initial message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove unnecessary benchmark script", "commit": "374db23669e203c98e5335b9a7abe9aff2110537"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improved cache logging and socket cleanup", "commit": "f3d12a257e7a43e3292654d7f67f05d823f16283"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove generated pycache file", "commit": "8e825a90c6e575f114b380312bb9c5726577b8b7"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit table results to 30", "commit": "01d8093e7210910016ea5d6d8bbc5d8f2514c14d"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit message retrieval to 30 entries", "commit": "d93d48ef7e023c62bfa9b64ede20cd9f86c3242e"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Improved message handling and added scheduling for event dispatch", "commit": "da72a15068fe14eeb2b50b4cd3342fb4b70b0c79"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule functionality and minor UI adjustments", "commit": "99d335ac244c2258d82821344fa517857a782f4a"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule benchmarking with custom messages", "commit": "4f1a48c197fcad25d80873bac55cf66f7ff99382"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound on new message", "commit": "5aee606d5d65e71afa8366d24ed4632f662a9126"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound and improved chat input functionality", "commit": "14c59ba5c0abc7d1331e022cc99222223ea21526"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Improved connection handling and PWA support", "commit": "b2ca373081bdd7514b0f849dc1033edfd3f76424"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added favicon and manifest for PWA support", "commit": "7d05bd9da45489c02a9b057eef86d45e2ca90049"}
{"repo": ".", "date": "2025-01-28", "line": "docs: Added display and start_url to manifest", "commit": "4da635502bca60efd0cc59aa4df236d7b99c2ec2"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Reduced padding in chat messages", "commit": "d69c75c6197e857ad61e4dbc872b5ab5872c4837"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Add data attributes to message elements", "commit": "9e94210bc3f3b1b614a198591c52f404d84a8be2"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added user color and updated message display", "commit": "84e5bac1b93d5d1c124d303e6b08a29baaf4977c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add utility service for generating random light hex colors", "commit": "284d38096c7c5b1201f261ec7a5a28ed457952b5"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added avatar color and text color", "commit": "93b2f6cc41f08e21241642976b90e3dd98dc37ec"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add linkify functionality to message text", "commit": "9f652ece1bf0498f9032f94b77becc96b6eff009"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Increased result limit to 60", "commit": "16afbb4e15f370babeedfc2aa917daa0292da5a6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide avatar and author until switch-user message", "commit": "41927b7ef439424326cc58e3939f476e04b8eabb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and switch-user class for message differentiation", "commit": "75ec590be5fc3f446c97549d90c135966142ac25"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Show message time on last message and switch user messages", "commit": "0e821f8b588def99f950fecb9369456cff086e0b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Ensure HTTPS for external URLs", "commit": "931aae5134cad80bf7f5ba87fe215a03761f081b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added html-frame.js script tag", "commit": "c558dc2d79b90e7424cf4311747f077332b0a193"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected typo in script source path", "commit": "5f3dac8bc6b702735383688de44ad7609264742a"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Ensure URL is HTTPS and handle relative URLs", "commit": "4442f75ec50d3d27cfae1702459d5f8f34ba415b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added padding and URL handling for HTMLFrame", "commit": "030942db0984ac0f3a4072581d58d81fad03ef91"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install prompt and button for PWA installation", "commit": "438fad301447e3265ff7484606f8222b271e4d9d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to navigation", "commit": "3e4b6b00620f8cf2c8b8c63918e6c93d2987174d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide install button initially", "commit": "1f5dc57d6f24b17fa66ab5692038e005cc444378"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render messages with HTML for rich formatting", "commit": "03c72e85f72207a7b2480f881f7b0cb7055c5feb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added message template with markdown support and styling", "commit": "561a915e30274d8b191678135912313ebccde70f"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project to snekbak", "commit": "d7c003c4096f8cfed8f4edd517f41d45f4f8b501"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project from snekbak to snek", "commit": "f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added documentation and initial form API support", "commit": "b562d171674c2f75592ff3a0dd25b51d2a2457db"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added cache directory to .gitignore", "commit": "82de0f304469e6214169a2bdcf9c65673baa9e76"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added docks to message template", "commit": "80f1bbc05e612c45fb2ccbb629a6aa3b468c627e"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added snek image", "commit": "9e89e27c6688b0e05e4a10a0538d599f82278e64"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added notification audio and scheduling functionality", "commit": "af399e3b72c772ed97e943e7d71dc6384ab8ccc0"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added Docker configuration for deployment", "commit": "3be25285f4f0afaaf991ee7cc0a8f71854e8de4c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Enable markdown rendering in message display", "commit": "75cb7605cd5e8e91cab2ffbc9000eb5987e40136"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render markdown messages with raw HTML support", "commit": "4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5"}
{"repo": ".", "date": "2025-01-29", "line": "chore: Remove compiled python files", "commit": "f69586ccf7975be0bdd24659d6acec068f5183d6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added requests dependency and template extensions for linkification and python execution.", "commit": "bca39a612cad5f340864a4dc62d94cda962985f9"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Style message images and add time ago functionality", "commit": "5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Improve time display and linkify URLs in messages", "commit": "20d8d27f03e87bf06515d0664a00e669b92df49f"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Prevent linkify_https from processing non-https text", "commit": "3d6e1d2e943baabaf0b0875284bf18132bc3967a"}
{"repo": ".", "date": "2025-01-29", "line": "refactor: Removed unused padding rule in base.css", "commit": "5c4c5793899776e5d369f3949b4a8142a68ba7ee"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Display install button inline-block", "commit": "a8e3ad1af9f683ad25730ff48180c5306f72e1f6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added username to chat messages", "commit": "99cea506de5ea0c5b373869b7c28d965b8af55e6"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected calculation in timeAgo function", "commit": "03e90039695abc0fdc9276980bd8728bd8951f05"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added SSH service and basic gallery styling", "commit": "b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Initialized SFTP server with basic authentication and file serving.", "commit": "15de277a5be330fe6962e5271c537e3b5ef40de4"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Enable unbuffered python output", "commit": "8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for response messages", "commit": "4de93489ef01bf070f461915989be611156121dd"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Improve socket error logging and flushing", "commit": "1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for incoming websocket messages", "commit": "312b9eeecaee5d16247a7f0c694e3168893c389d"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Simplify error handling in RPCView", "commit": "c6f43931664c01c597c642e35c64ec49f3008101"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Remove debug print statement", "commit": "5fd03efc301d722a5ee09c8b0cef6d04c1130fd3"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Handle WebSocket close events and improve error handling", "commit": "780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Added logging for RPC exceptions", "commit": "cc3b896d2cd80affc251434800844829cc3fb6e1"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Use internal send method for RPC responses", "commit": "0a70e80668a598c909674c78b654b5ad8e6afce5"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Buffered RPCView methods", "commit": "bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Use `send_str` instead of `send_text` for JSON serialization", "commit": "010f3b03a0983843c74219484e78c50d595da6e7"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Import json for RPC handling", "commit": "8f502af84eea60b5349fd1980d352f0f8e001502"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Ensure messages are flushed to console", "commit": "10c7232a8f6378eed8f5b4adecca8d582d57a069"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent premature socket deletion on error/close", "commit": "88749ce05c7c4e9b5e238d16cb9fa4053f092fc1"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Unbuffered websocket subscriptions", "commit": "2ae2e8450cad47031067f3baae3b09ff521c5c87"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Removed unnecessary style block in message template", "commit": "495543144d464121af0afab6545a5267ad561a57"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added highlight styles to web.html", "commit": "cfd3e7881eca77d10d32de2440a9d2b03aeaea96"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Streamlined message template rendering", "commit": "efe12644eda127170a3d60e086fa31ed940fca6e"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent default event on keyup and change events", "commit": "7526bcc816ffb759e3708f30167b4d3367955b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent form submission on Shift+Enter in chat input", "commit": "1999a6c8d8dd4fdbd48d5553a1704dfa065275ee"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent empty messages from being submitted", "commit": "ae5fffe5e0faf948a22feea0e651e08a0ed559fb"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent submitting empty messages", "commit": "663ab415101e5da31fb71e3e9e3b433fbd6c3031"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added query endpoint with security checks", "commit": "3796c7c54767b5de18c5310d20c9dd3c5aafdd0c"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Made channel query asynchronous", "commit": "f6f99684307249b6650dcbbb3168db1ebfa71e73"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Disable autoescape before linkify and markdown", "commit": "0c68c4e62255a307ecb48cba011ef38ace935eb3"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Allow underscores and plus signs in usernames", "commit": "4185bb3a69ac66d7b6614acf76bb5a2f613e0b82"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added emoji support to templates and app.py", "commit": "928969b8b6266298317ea4f7ca3e6b2cfbd42e82"}
{"repo": ".", "date": "2025-02-01", "line": "refactor: Removed unnecessary timezone handling", "commit": "feeb94c9cf08ebee6d42165988b1d51030df4c33"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added highlight stylesheet link", "commit": "e0ed4491b414c51b54e4c3ebd10cbebb46a903c6"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Add basic syntax highlighting CSS", "commit": "98d89dbc5f45a61ab6335e38d6e4a1df39bcc621"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Applied highlight.css to message templates", "commit": "a06e3f404a15d8115fa65ba8533ff7774baa0beb"}
{"repo": ".", "date": "2025-02-02", "line": "feat: Add user data and audio notification check", "commit": "99fc9118b37f8564cd6e211d3d77ef997592f361"}
{"repo": ".", "date": "2025-02-02", "line": "fix: Resolve issue with user data initialization", "commit": "7d750db1f8235c8231699c2da39c1075ac678841"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Improved code display with word wrapping and line breaks", "commit": "3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in base.css", "commit": "23c8ebca73ac49c826434d40fd1e1fd2e3435957"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in message content", "commit": "38a24e9a12355f93776c9aef0b9caa5afb075531"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Commented out padding in message list for testing", "commit": "83cc0f613708ed27b928ba45b330c984d82dd546"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added padding to message list and hid avatar", "commit": "079187e1b460e5554bfec8b9658b5059cc3d51c6"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Hide avatar on message list", "commit": "f4a5536dcf1e27a7e8319488f8f39a8acfb818a2"}
{"repo": ".", "date": "2025-02-03", "line": "revert: Restored avatar visibility", "commit": "fe707dca4ea0bc2ecaedcda292f1ae636fce2b93"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added upload button functionality to the chat interface.", "commit": "b48a901e3385617d36511e251c4e7c62498e23bc"}
{"repo": ".", "date": "2025-02-03", "line": "fix: Remove unnecessary whitespace and improve text wrapping", "commit": "f395d1617394045cac7c41af0cd5ce9d6ef55ed8"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed setup.cfg and adjusted code for improved stability", "commit": "084f8dba2075aec93d9d88fd7cdd7f67fc63a212"}
{"repo": ".", "date": "2025-02-04", "line": "feat: Added drive service with upload functionality", "commit": "6f9adfe67fd551dd99746c40bb55706a7ffcef3b"}
{"repo": ".", "date": "2025-02-05", "line": "feat: Added :snek1: emoji support", "commit": "b6185a95f3fcbf539ec0ba767d4c0923092f8e82"}
{"repo": ".", "date": "2025-02-06", "line": "fix: Handle failed RPC calls and track success status", "commit": "203314b209030f297cd888685bb68721bc21c61b"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Correctly detect code language or fallback to bash", "commit": "386d9c3aaee80115241866ae72df9fad3ea3c714"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Handle empty code highlighting results", "commit": "d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Escape code blocks in markdown renderer", "commit": "a301e2c5bfb8286f63a48c2860162780f95e820d"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Updated html.parser import", "commit": "cfa2af61b81b613bf2b8177b8acff1ea8b7c8576"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Add code highlighting with lexer selection", "commit": "51f1b1d86e4813c10e2750f0771c1bdcc1274bfb"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Replaced navigation links with emojis", "commit": "9840c8eb03f969330583e9c3a7b28dbb5548f7d6"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Prevent lingering websocket connections on disconnect", "commit": "7ca2bc5776213828a31c7fc237784a0a73c6f759"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Updated username regex and added user search functionality", "commit": "f291c0f2e4081fde4ed55d6cc25fdcbb1952af70"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Initial app template with basic structure and links", "commit": "fcb05903f3f583ce8532d65ee7edf1ad8df91df4"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Relaxed username and password regex constraints", "commit": "8d0d709e18be0177b99f76f320eeb02b70bb41b0"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Added user search functionality with HTML and API endpoint", "commit": "d7b943dc8c8f485c975730d6054e32e67db36c91"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Implemented search user form", "commit": "49eb76dc8b93cd422a9fda40cece480a573b8524"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Prevent potentially harmful queries via search form", "commit": "60ca3ec7918073a2fb3ebe81e9ea733225391d99"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Style updates and search user form improvements", "commit": "5154811b29ced87375ac457fafeb25305f64a954"}
{"repo": ".", "date": "2025-02-08", "line": "style: Adjusted chat message container styling", "commit": "a8fea31a326c7b9868dde866be553bb9f84eee88"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Optimize database performance with WAL mode", "commit": "06b539b8845c49ec6d2789876ef4069b8df77117"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection in UI elements", "commit": "b169fa4792e02303ea0f61e5d83b3993a8f72f05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection on title and logo elements", "commit": "ad4847a78e2945fe4f57ae16262e0c2a91a804f5"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Prevent text selection in navigation", "commit": "bda5cfd52d5272742d147e93b506b87eeee04e1e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor search user view and template name", "commit": "afa40ada778c5d4102bce19312456adca51f70d0"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Implemented user search functionality with basic UI", "commit": "a42c2bdf5d2cee53c16e8dc123fc4473107ef203"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent SQL injection by enhancing query validation", "commit": "e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added branding and updated view templates", "commit": "a3cec5bce0386c8c3012262aa4a582241786220d"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added WebView and channel routing\n\nfix: Corrected form validation in LoginView", "commit": "78f9679f308016320b64cd49bb3552fb63d26d27"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added time descriptions and user switching in chat window", "commit": "e7cd397e0fe98074833e08880d915516718adaf5"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added custom scrollbar styling for chat messages", "commit": "feb5234b3b581936d45ac328b23de7da8f375ee2"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Improved chat message rendering and layout updates", "commit": "ecb77cf361f0d55b512028701c67c1e347836e6e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved layout updates after message insertion", "commit": "bfca2bdf734c9b9522186c1ff1b6479f93f34658"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor CSS for improved layout and styling", "commit": "83121f7fa99df690a3b9029556aa023226cf22ef"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Correctly append message element to chat messages", "commit": "dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved text wrapping and code highlighting", "commit": "cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message appending in chat", "commit": "a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message content styling", "commit": "661eba7161c1d869d73f04641878521fbdf8b72a"}
{"repo": ".", "date": "2025-02-09", "line": "style: Removed unnecessary block display from message content.", "commit": "e75836fe879e4f7e2a8bb34ed8ca901cc624ce05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message time display", "commit": "0f400a0b6aa04ffdfd8de1e26be3318a39a174a8"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Enable CORS credentials", "commit": "49c0f932ab3e5705380a57cefa8da9ea7b9967d3"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implemented online status and ping functionality", "commit": "54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Display online status for channel members", "commit": "688e7fbf0e8977f442edc41cea1ac2a06f1ece40"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Increase online status timeout to 20 seconds", "commit": "48891c438694d37cd1b8338a2cc1f96f7647e77d"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Include last ping in online user data", "commit": "087ab1a8a55ae58b52078dc5cb7de7db65132e84"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implement online user status check", "commit": "3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added private chat functionality with DM creation", "commit": "8a59ddd210bb3ab3d29f9207afbf887988b528d9"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality", "commit": "ca463b79a88687a76d9fab851b8f2ffc7e071e81"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and updated tag to lowercase", "commit": "8fe24f711cb3e796471145a086c4b72289e12e1a"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat redirection", "commit": "bfe4b351c1fa750c9d12d3ae880928cca0346bba"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat redirection", "commit": "be35a6caf07c51eaf79625ad914215bebb9b11c5"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added base URL property and file type handling for uploads", "commit": "2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Display uploaded files as links instead of iframes", "commit": "2541fc536aa45630ec55297c58787686e4154fab"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added echo endpoint and noresponse return value", "commit": "b6eba608435be4d798e50328f9149a8768a5cc8e"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and updated templates", "commit": "3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Add channel list and improve DM user display", "commit": "37da903936e4ab85fae254421c356966991d53e4"}
{"repo": ".", "date": "2025-02-15", "line": "```\nrefactor: Improved socket communication and removed unnecessary prints\n```", "commit": "1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Refactor socket handling and messaging for improved user management and broadcasting", "commit": "53be4b060a1fff9cf58c7224dc4522bb0cafa852"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Add channel tag to RPC view data", "commit": "9c3abdec2613c4d492c363cca8a07882dd3d8135"}
{"repo": ".", "date": "2025-02-15", "line": "fix: Correct channel UID retrieval in RPCView", "commit": "d1396801c05688e15ad7f1082dab2576b9a2b011"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed YouTube videos directly into the page", "commit": "7c4334fe7b5b7e6ba44627a4f084638af51dd44c"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed media, images, and YouTube videos in links", "commit": "c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Correctly linkify HTTPS URLs", "commit": "263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc"}
{"repo": ".", "date": "2025-02-16", "line": "refactor: Remove unused YouTube embed code", "commit": "7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Handle file extensions in upload URLs", "commit": "be956a13db0941008802701196ca5e3870ebf2aa"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Ensure images, videos, and iframes within messages are responsive", "commit": "ea4196af8f7d7e0c97c004a07817bdc1dac999f5"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improved media handling in message content", "commit": "f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement scroll to bottom on new message", "commit": "2e69ac5921c16ea0cda1a1b7c84dd63ff458db62"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement timestamped pagination and focus textbox.", "commit": "8c33bc63d6cc623d0782f14c96a42c612accbc75"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "477ca5917a59d3720a6a5ae01b307dec1a74cfaf"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "9e3b9ae326b6ec632f3280018ea68d1896645b9a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use offsetMessage timestamp for infinite scroll", "commit": "33bc695cda6bf5889d802129117ed59992b87143"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected method name for fetching messages", "commit": "aa5703e62f891fa7db09f07e6ce060875f5990d3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected offset calculation for infinite scroll", "commit": "1792686531fb440d9f453422bfa648c890c255d1"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust offset for infinite scroll", "commit": "3ee7c6d8024245933569fdaf3f99a71afd14fe8f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "95a8a458420dd2ebdbd6f7c03bd58c649985933e"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "162f89f9d0f7e304d355dd8f626ce2430dd840bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use first message timestamp for infinite scroll", "commit": "104ee277669ee2cd55eb97b674f7a2432d31bb5a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages when not scrolled past half", "commit": "60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling based on scroll position", "commit": "2fb6be753efa7f2cc1e0183551a1ad655388b970"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "24cd378c9d8f857f4f28af1069a2f5523a6441d8"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use scroll instead of auto for chat messages", "commit": "8b98935d11496d51a0007db4b78391dde7a69163"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar and improve chat styling", "commit": "5b03ecda3f3f9ae515dd00a4e421255535a2f215"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "3230c9f93bf9c3ae1b27474eac1cfc35f626d387"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improve infinite scroll trigger position", "commit": "6c58f4b26c628881aee5cbe0597ba489f705e42f"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust scroll position threshold for infinite scrolling", "commit": "bc8a296223f3a2c6e07b126a78373aa5bb40399d"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll position check for infinite scrolling", "commit": "c77d2fb782258f787c5b52e3b27c5a3b0d468903"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll threshold for infinite scrolling", "commit": "2e86ca2a3f1a4a8c746eecb46038a216b9706cdf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Disable extra scroll loading", "commit": "1a608d8cfb1a3dcef9591b214fc54904615148bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Enable infinite scroll when near the bottom", "commit": "f0d76bd46af06637a21526c58d97f4b4d57f87dd"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Disable scroll loading for now", "commit": "1b6ebf50080b0b86256e639031f36da92b8990b2"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected infinite scroll trigger condition", "commit": "6bdc6a7347a492f155629458c9b277cc16e04666"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected infinite scroll logic", "commit": "c042af8b800879ecfbb817089119aab75d839c32"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected initial message retrieval for infinite scroll", "commit": "2595594c3a99f6613e8e2194977fb1707c9f8b98"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Added comments and improved message loading logic", "commit": "6555e4f8266b01963cfd660a4e175b01ab615c0c"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Remove unnecessary comments from web.html", "commit": "2ab4341d0099799a84c0df6d91de33e1c5f69470"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Update manifest and app.html for PWA support", "commit": "e21880b4f5fd15259e09c207ce54d8e86bd61ac7"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Removed display_override from manifest", "commit": "6c7266f20403f1f190c8b41f22d653c041dbbc77"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Added 192x192 icon to manifest", "commit": "c745f609976397de0fb0cf7dec80205239b44b87"}
{"repo": ".", "date": "2025-02-18", "line": "refactor: Switch to asyncio for application startup and debugging", "commit": "3ccbe8be5c604d2683cf55553d1f11c674f6b930"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Standardize environment variables in compose.yml and add logging to app.py", "commit": "ebb520dd4a80b513d1eb6fc6ce90e6b46f905100"}
{"repo": ".", "date": "2025-02-18", "line": "feat: Integrated profiler for performance analysis", "commit": "c6620ad70afce9407c16793de8ab4fea35523d81"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Sort profiler stats by query parameter", "commit": "91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Corrected typo in profiler sorting", "commit": "60404c6fd31894f3fbb6ce31ba48f1750101748f"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Added a1 emoji and long emoji", "commit": "736123c4aa313c51a7e0daee8cdd6dc7583547fd"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Increased Gunicorn workers for improved performance", "commit": "2ad5a7b1f49704baf7b890fcfda7a87fddd456f7"}
{"repo": ".", "date": "2025-02-19", "line": "fix: Reduced Gunicorn workers to 1", "commit": "e06824f4ec703388b7d55beeb5f1b3ef12452226"}
{"repo": ".", "date": "2025-02-19", "line": "refactor: Increased Gunicorn workers and moved app instantiation to global scope", "commit": "821db3cb1a67c20a968ac1dd8ecc4263e511cf16"}
{"repo": ".", "date": "2025-02-20", "line": "feat: Improved database indexing and UI enhancements\n\nThis commit introduces database indexing for improved query performance and several UI enhancements:\n\n- Added indexes to `user`, `channel_member`, and `channel_message` tables.\n- Updated CSS to include a container with improved styling for lists and links.\n- Modified `manifest.json` to set the scope to `/`.\n- Refactored `template.py` to handle image embedding and YouTube links more robustly.\n- Adjusted `app.html` to display \"Channels\" instead of \"Chat Rooms\".\n- Enhanced `search_user.html` with a container and improved styling.\n- Sanitized user data in `rpc.py` to remove sensitive information like email, password, message, and html.\n", "commit": "3623286a9dfba330612c42e579abcca63ab186ed"}
{"repo": ".", "date": "2025-02-20", "line": "refactor: Reduced gunicorn workers in compose file", "commit": "a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Added sound effects for mentions", "commit": "54920e1545ffc68e2f928d3d042f5f11080f0d41"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Add notification sounds for different events", "commit": "8ea41bb592b86e2f49b2f838e03006bc04472da5"}
{"repo": ".", "date": "2025-02-22", "line": "refactor: Moved sidebar channels to separate template and added channel notification", "commit": "fbe95d6631dfac2edc4c8600922020be4e15eccb"}
{"repo": ".", "date": "2025-02-22", "line": "feat: Add channel sidebar with message counts and highlighting", "commit": "076fbb30fb51ecfb5b15d394c760f55dac26e1c1"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Added avatar support and updated message view to display avatars", "commit": "5af4e5754b6902ae13c798d9793281d62b684590"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatar ID when requested", "commit": "e280e8776457605bbb5548fe9de5328b7b04bb8a"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatars", "commit": "162cfe394558a085894a11cea57b193d6108b90e"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Updated welcome message and registration buttons", "commit": "da1be6301c79cf76383e6568f91ee23bdf5119f6"}
{"repo": ".", "date": "2025-02-28", "line": "fix: Disable login requirement for avatar view", "commit": "66b85d146abac25df83edc1975db209b9d43fae7"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add last_message_on to ChannelModel and update on message send", "commit": "4620ebb800b5dd848ec28713f1afa20416698922"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add index creation with error handling", "commit": "e469e27abfedc0b08b483e0715b4dc9b16240c5e"}
{"repo": ".", "date": "2025-03-03", "line": "fix: Correctly handle trailing commas in link targets and improve upload view display", "commit": "45e3239cc06cdab0a8e5c1c1ef56593f65e750ea"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement push notifications with service worker", "commit": "c3c94461c295ef4c6219051369472f983267437c"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add notification read functionality and unread stats", "commit": "578c182f2707a5f5b7c93f421e2035f7271aa60c"}
{"repo": ".", "date": "2025-03-05", "line": "fix: Handle missing notification model in mark_as_read", "commit": "580ec5ab0d57a542ab38b25a6c508804d5bcfa21"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add new_count to ChannelMember and update notification service", "commit": "84d7b11f24b37cdc41ce9d5bb24be4080af14be9"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display new notification count in RPC view", "commit": "d7851e645785fd707a7c7fdc5b6fff036e0c80f7"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "e1324e99bf06018a804a1a3dcc83f96cde04b1af"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "afbf53938bd59e1e03dfc011063b27134dd0c054"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added new_count to notifications", "commit": "8d3d7327d777ae3150ecaa237e137f8221310dfa"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Remove unused new_count field from notification model", "commit": "4df6055566d61c769ae1759d81900d138093136e"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment notification count and log insertion", "commit": "dd11c8da5acc5ed97a278a705e7282edc7d50bc5"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add logging for notification and mapper updates", "commit": "8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Allow channel members to be created", "commit": "30fe0bfae7f2bc9badcb16f734655e661fe976ad"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Return model directly from query", "commit": "edb35b57070a5659e8491a967880d816f2d07697"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display username when inserting notification", "commit": "0613f6f54de9247c17b4bae924197ffb4cbd2966"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Disable banned, muted, and deleted checks in channel member query", "commit": "1807cff67d3a4306f122d7df4436cc88137f299c"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Implemented threads view with basic message display", "commit": "5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Added threads view and related model updates", "commit": "a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Prevent race condition when reconnecting socket", "commit": "e3afc1ba6e97378688027a60d6d98cc19a519a8c"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected avatar styling in threads.html", "commit": "37f6725f2f7e36ec03416f191c9d16cd864991ea"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Display user avatars in threads", "commit": "095be5892db198d0a6356c8700ed0c038e419a29"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread avatar visibility", "commit": "9292e3b8f3b64084d6bcc0b13dd42d015f4799d9"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve thread display and opacity", "commit": "24260f9c371ab2d989441e391f513f6460eaa1ec"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Unified styling for chat messages and threads", "commit": "1b72063a5b972dd726c647b7397f0ced16bd66c2"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved thread display and DM name handling\n\nThis update enhances thread display by adding a name and color, and fixes a bug in DM name retrieval. It also refactors the code for better readability and efficiency.", "commit": "5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve message styling and visibility on mobile", "commit": "98c2213a862b253f8f967f428b0b248bbe3a32f7"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved user search display with thread-like layout and real-time updates", "commit": "0a9b66d2f76a2c4418db7149b17729bf8a2dc811"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Set default page titles and update login/register titles", "commit": "6ecd356cc08e17596ff6b5007c46def2bc17c851"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improved form submission and change event handling", "commit": "804556b74d8caa5e3a79a03cd1a8d7870843b898"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added meta information to base.html", "commit": "7c22a70722db3fa97a813c30c01c4cd5462138eb"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort threads by last message timestamp", "commit": "8e195a49e3e914a4b241e95378bd9a07611715a8"}
{"repo": ".", "date": "2025-03-08", "line": "style: Improved input field styling with focus and placeholder transitions", "commit": "c9113ca09500c5b3cc277fb09b9607a505d39f30"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added head block to base template", "commit": "62aa15a4b4d6514824378cca73084c9ce2df903b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented back button styling and layout", "commit": "ad7eab9717848584369ded6e19babb6a7b9f5b98"}
{"repo": ".", "date": "2025-03-08", "line": "Revert: Undid auto formatting", "commit": "e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0"}
{"repo": ".", "date": "2025-03-08", "line": "Merge main", "commit": "dd5a9a23e8e452a03f7080656608617309bf73a5"}
{"repo": ".", "date": "2025-03-08", "line": "Merge: Tweaks for login/registration and base + image roundness", "commit": "aedfe9aa947dcd2262c825af5a4d977eb298ccb5"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort channels by last message time", "commit": "a219ce4d79a15ef900583ab025fb0da1df79ace3"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Filter public channels in sidebar and add private channel section", "commit": "d6061cb45b68a7b393b0e35a560d5d7bea4b9478"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message, handling null values", "commit": "24a504e3a7383c7a338fbe3ee09411547eed58eb"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message time, handling null values", "commit": "11b8f0e744fb9d6b05ce11b7475bb3f51edee96b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented form preloading and autofocus on the first input element for login and register pages", "commit": "0266b2a559952d0ff767b251c2921704a6aa1abe"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected semicolon in loadForm call", "commit": "fd07001983fc3d3015ac7064461c14b8486155e6"}
{"repo": ".", "date": "2025-03-09", "line": "feat: Preload form and autofocus first input", "commit": "d9ac1813ba8ddad9fb602730cb2cc763aab4bc23"}
{"repo": ".", "date": "2025-03-09", "line": "fix: Sort threads by last message, handling missing timestamps", "commit": "91d8f3efd16431fe99b0e60927d1f6d9b6587f7e"}
{"repo": ".", "date": "2025-03-10", "line": "fix: Improved search user page layout", "commit": "c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8"}
{"repo": ".", "date": "2025-03-11", "line": "feat: Improve file download experience with suggested filename", "commit": "c6c2766381f75b058fb61f91556788b0720b058b"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added reply functionality and improved time display", "commit": "5cfcafe0821b3cceb753b9ddb4076a79f26a88c0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Display creation time on container", "commit": "c55927aa9c7e575544901bbee41cb9a9d3c6437a"}
{"repo": ".", "date": "2025-03-13", "line": "style: Darkened the overall theme.", "commit": "0fad298fc078e2a3ea1afee71ee92b99b83427b0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added padding to links", "commit": "0f950218d6d783c4738966e15b2746c14043c82f"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Improved reply formatting with markdown and blockquote", "commit": "d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139"}
{"repo": ".", "date": "2025-03-14", "line": "feat: Increased update interval for times", "commit": "17c9731b9f8be07b247aeed29ba2ab1319e408f0"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Centralized input styling in shared.css", "commit": "5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Applied updated input styling across pages", "commit": "752f3df13a548d22646f87a87940dc64e15587f3"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor app modules and update script types to ES modules", "commit": "a4d79b06c49ec9f605336bf181e98455c8acd460"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Convert files to modules", "commit": "a9663c8170dd2f925100eaa50e8c0019c5eee683"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Refactor socket and event handling for improved reliability and structure", "commit": "4a8a614adb5e15cad18414214d30ab83464eae14"}
{"repo": ".", "date": "2025-03-16", "line": "refactor: Improved code formatting and spacing", "commit": "c9c070c497bb9af3eb5bb9915f221ec00b56b832"}
{"repo": ".", "date": "2025-03-16", "line": "fix: Reconnected socket on error", "commit": "e62a8554090009c7914b95833066ad46251da01d"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Changed button colors to dark theme", "commit": "819cf8381c287e2ee88fb1d6fe789e30a1a33eff"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted upload button background color", "commit": "2ba28c193a826e8c1f9647f06bffb6084166c9f1"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Added Umami analytics tracking", "commit": "287e10d8aa8feb2590ad48d9412ace74a9432baf"}
{"repo": ".", "date": "2025-03-17", "line": "fix: Scroll to the end of the message container", "commit": "54416ee84f88064897a824ae2c3a9e0ef2c1ccaa"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Refactor header layout and logo display", "commit": "39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill and update templates", "commit": "7fa2817f773b47737236f4bb700023bcf8b483f1"}
{"repo": ".", "date": "2025-03-17", "line": "revert: Removed unnecessary formatting changes", "commit": "965dc930a900a5080e225bb492be2b799daed22f"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill", "commit": "825ece4e7868be28ba03c4fde5149695b0dd9dc5"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Added dump script for public channels", "commit": "3c6a0944d68ca16250ec9364d7f006b0e7eea6e8"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Improved dump functionality with JSON output and user information\n\nfix: Removed unnecessary print statements from cache and dump modules", "commit": "70db15bf27c7a8fd8bf112432f02754f01bbb3d7"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Refactor dump script to output to dump.txt and improve message formatting", "commit": "3960390ec45f427979ebd2b81c1a21666a47e71d"}
{"repo": ".", "date": "2025-03-20", "line": "refactor: Update session management for user registration and login", "commit": "5ba239caa8928a078362af0e6e2d1a4626bd508d"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and updated Dockerfile and compose.yml", "commit": "7dcabde2ed0ab699ea3033f7788381e85c352b97"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and basic shell environment.", "commit": "013d4adce57f4afec5176bbdb5e2225d529ec3b7"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal page with Ubuntu option and channels sidebar", "commit": "6e68408ddfd8be2376f7453deac8f63a6bfb93e4"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Add channel list and user context to template rendering", "commit": "604e27ce10dee59b1b3f6ebd359ee356b085df2a"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c72b015073347e86f2edf1e67544b1ae31e929b0"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Disable r executable and add vim and htop to apt install", "commit": "78c631e6c79b4b69b0e202a301b710c4150e6dbe"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c8461342fd8d260da69c84f27f9e9d13b3430942"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Introduce ThreadPoolExecutor for asynchronous task handling", "commit": "0bc24e8d2ef4452efc7be5286288a2531908ea55"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Improve terminal display and handling", "commit": "c2d9af807a95900c4fecd7a1929c8d92393a955d"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Install git during initial setup", "commit": "c5c160baae67d7e5932963f8501ed7d56dc35c21"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Updated drive functionality with new views and services\n\nThis commit introduces a new drive feature with the following changes:\n\n-   Added DriveModel and DriveItemModel to represent drive data.\n-   Implemented DriveService and DriveItemService for managing drives and items.\n-   Created DriveView for displaying drive contents as JSON.\n-   Updated Application class to include drive-related setup and routes.\n-   Modified Makefile to use python3.12\n-   Updated src/snek/app.py to include drive view.", "commit": "7b32a7eba4d5944142a3b40616d37b0862087371"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Add URL to drive items", "commit": "dec2281ac88d151afda016fc01e833dc2f0aa89e"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Correctly fetch file extension from item", "commit": "1de2c55966c0ddf0fb663b935427d2c005f0fde9"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Improved terminal session handling and websocket integration", "commit": "529606955a545bc25cf5899f2c79d3660bcefd54"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Increased websocket thread pool size", "commit": "af4a70e8949bfde704a0499177050fdeab5300d9"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Display user color in sidebar and update new message count", "commit": "5390b8bdc3dc04645258ed758f3894de00008e80"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Prevent errors when user data is missing during notification updates", "commit": "877ef7970d5b7bb5f8fd0af35adad5d8d071b14d"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Remove debugging prints", "commit": "87c189b3fe897cc15ee6ac311e987bf9fe7811b9"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Cleaned up console output by removing unnecessary print statements", "commit": "71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction for notification creation", "commit": "bd5bb5ae65d6cb870d090382b106b705833d4cf1"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Persist RPC transaction changes to the database", "commit": "13ce09a5c50ddba8956dbeb838f9dd1bcafe184a"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task runner and task queue for asynchronous operations", "commit": "145373399dada2f4cee54af0c99e62d4a27a0f99"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Improve task management with asyncio.Queue and error handling", "commit": "e6f702a6b405f1dae0ce719177c6f7bf5b636ad8"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Remove unnecessary database transaction block in RPCView", "commit": "9d5815ed1028354ac61f2d506530147a02c476e6"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction management for task execution", "commit": "8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task execution time measurement", "commit": "6ad3844f037735e268b42247fcc6e8605cc13f07"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Asynchronously create channel messages and improve socket broadcast", "commit": "73e8779bdc42ec5f5618fad3d563544d1fad2b69"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Ensure notification creation after socket broadcast", "commit": "6f043d21390d13d37772c8ae145d7a66b3919529"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Await task creation in chat service", "commit": "9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec"}
{"repo": ".", "date": "2025-03-28", "line": "feat: Initialized Ubuntu Dockerfile and terminal environment setup", "commit": "5fbcadad8bad7b8dd7ac3279938ff50db6b5d380"}
{"repo": ".", "date": "2025-03-29", "line": "fix: Adjusted avatar size in message template", "commit": "fe1b3d6d191176111538f1ba06018e14c44ca8f9"}
{"repo": ".", "date": "2025-03-29", "line": "```\nfeat: Added webdav support\n\nThis commit introduces webdav functionality to the application.\n\n- Added the `lxml` dependency to the `pyproject.toml` file.\n- Implemented the `WebdavApplication` class in `src/snek/webdav.py`.\n- Integrated the `WebdavApplication` as a subapp in `src/snek/app.py`.\n- Added necessary imports and code modifications to support webdav.\n```", "commit": "29139d5d0c18ad2b0ebd32db9a0b629c45c0a651"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Initial implementation of WebDAV functionality with basic operations", "commit": "9a923f6bddd73df27af80ef6c8e2313816a07a48"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented basic WebDAV authentication", "commit": "886d21999c716ee306318796f3f159ba085f9618"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Use home directory instead of drive for user folders", "commit": "6dca3a96e1cd23cc26d69aeee31ae45e14977bbd"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented recursive node creation for propfind requests", "commit": "3926b2d837bef181546d826b41821cf90fc755a8"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Updated home folder path to drive and removed lock implementation", "commit": "d5917b94540aee206935354f438a6a7f893278ec"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Remove unused LOCK and UNLOCK routes", "commit": "8058e4a4b0aee254edc76fa28b2c4336eb393c4b"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Handle potential errors when creating user home directories", "commit": "2a47c0ba5e7344d44c3acd15d8f3efeb11722677"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added settings page with profile, notifications, and privacy sections", "commit": "32e0c959e8589f574d1c46b96d35f49c98721566"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Improve responsiveness for smaller screens", "commit": "87b48af551d2ed023f77ca57d033ed0079d303f3"}
{"repo": ".", "date": "2025-04-01", "line": "refactor: Reduced message list height", "commit": "18b1ec20b67522cf816b29f3cde64a935ec5b330"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Removed chat window element", "commit": "7c52c2d9d5f10623ccf479918ea5baed247a07b5"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added footer styling for improved layout", "commit": "fbd4fa4e668628c11d8b592718cfcdcca71a3c0f"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Style adjustments for mobile view", "commit": "d24627b35fd2201e6baad781a11fbae0c379f366"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted width for mobile responsiveness", "commit": "e3c997302b07228c0791d6307b429109b6eb3d53"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted form width for mobile responsiveness", "commit": "27c0abea3147e5e00a5196fa9b351141a9d17ae2"}
{"repo": ".", "date": "2025-04-02", "line": "fix: Handle missing last message gracefully", "commit": "b365afc910522a484ad257af6df7b607712c71f2"}
{"repo": ".", "date": "2025-04-02", "line": "Error code: 422 - {'error': {'message': 'Provider returned error', 'code': 422, 'metadata': {'raw': '{\"detail\":[{\"type\":\"model_attributes_type\",\"loc\":[\"body\",\"response_format\"],\"msg\":\"Input should be a valid dictionary or object to extract fields from\",\"input\":\"json\"}]}', 'provider_name': 'DeepInfra'}}, 'user_id': 'user_2wGl2Zqx0Xrkj5eQdwZSgLEFytg'}", "commit": "99b2beeab0d55242537b7ec810bfdd69feb47103"}
{"repo": ".", "date": "2025-04-02", "line": "style: Adjusted layout and overflow properties for improved responsiveness.", "commit": "81479e7058feda9954fd74810d1294fb92e7a1c4"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Refactor settings view and sidebar", "commit": "d10768403d221fbd1d50a520c083b8d1be1b3a19"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Added profile settings page with nickname and description editing", "commit": "69482207461eec9c3c64ec297231989aa248dd9a"}
{"repo": ".", "date": "2025-04-06", "line": "fix: Update icons in manifest for stability on Firefox Android", "commit": "c2b8061ac292f18949d81c660c5a314cb42bcc6e"}
{"repo": ".", "date": "2025-04-07", "line": "fix: Resolved web manifest icon instability on Firefox Android", "commit": "75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added IPython dependency and improved asyncio handling", "commit": "d71d5da6bcf22d2daf5ec59832f15fe02472b95c"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Introduce UserPropertyService for managing user properties", "commit": "d2e2bb811707b02f05cbf22d10ef1916b021c90d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added debug middleware and improved routing in WebdavApplication", "commit": "d23ed3711a464f1d796ed35e58dbcaf1db6b7d84"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Add debug middleware and improve WebDAV functionality\n\nThis commit introduces a debug middleware for request logging and enhances WebDAV functionality with caching and improved error handling. It also includes fixes for lock management and directory size calculations.", "commit": "13f1d2f390afdfc912d24bb63930c9ca47e05f94"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Support short and standard YouTube links in template", "commit": "b31c286a8b8442d48f9b0713a8cce41432c168d1"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "b0a97ad267b971f8ba298bb5a0e696810c08b026"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding with improved parsing", "commit": "c6575d8e525dfa2f574e1965cd3df4379cde7acd"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube video embedding logic", "commit": "6138cad7827c48a86b20d4015dce818dca348f04"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Style YouTube embed to center on page", "commit": "087f9c10b44fca9f29de862562b405ef5586f151"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add support for YouTube video embedding", "commit": "e6bd7aa15211ae0bd3be65be3a659526b1131eee"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added video embedding functionality with improved layout", "commit": "94e94cf7ca4bdcdd581dfe074728e93412c2a621"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "6673f7b615508f0c344fe0efbebe362f5236bd84"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding from text", "commit": "2582df360ab0667a3d29c46b92ad4abeb397d363"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "656ea5f90ee56a16b0f0047cace848572dc479c7"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube embedding logic", "commit": "c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Refactor asyncio and database preparation in Application class\n\nfix: Corrected indentation and removed unnecessary comments in profile form\n\nrefactor: Improve user property service for JSON handling and querying\n\nstyle: Minor formatting adjustments in template embedding\n\nfix: Corrected profile view logic for user data retrieval and form handling\n\nrefactor: Improved webdav application and file handling", "commit": "44dd77cec5639575cb86973eceb8d174d570370c"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Minor formatting adjustments across modules", "commit": "743593affe276ae8ffd3751c80fe88eb4c99ac7f"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Removed comments and added channel display", "commit": "0e6fbd523cd4f4279a4f230567504b30c9b3116d"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Improved channel broadcasting and added user UID retrieval\n\nThis commit enhances the channel broadcasting mechanism by retrieving user UIDs directly from the channel member service. It also introduces a new method `get_user_uids` in the `ChannelMemberService` for efficient UID retrieval. Error handling has been improved in `SocketService` and `RPCView`.", "commit": "3594ac1f5984953487e0c3423c9672b01e416c28"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Add stats view and cache statistics tracking", "commit": "bc65752ea252cdcd929ba0bd956455317958337a"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added stats view endpoint", "commit": "a1840cd034e7a4c792e2bcc69ff06595b1e2add3"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Moved 'r' executable and updated .bashrc for automatic updates", "commit": "22668f8a72994446ffaa109e5ae742bd61bd3bf2"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added initial .rcontext.txt file with facts and work procedure", "commit": "ec9af49f2903682cea978db15422fba4624c488d"}
{"repo": ".", "date": "2025-04-13", "line": "docs: Added a reminder to be rude but functional.", "commit": "9b49e659e575e99de717a5c64e1ba1c3c4039cb1"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve process handling and error management in TerminalSession", "commit": "823892a3021e674fea933b717565518dc1696031"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Return empty list when search query is empty", "commit": "4a770848a6dbc558c029b083a881becf7adef8d7"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require both logged_in and uid for login_required views", "commit": "e4b0625799d9efd89e7e9518278588158b296c6c"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Require login for search user view", "commit": "8ae9aac045e41fc84ebab102335a2613b3e22c08"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Allow profile description editing and nickname updates\n\nThis commit introduces profile editing functionality, including a textarea for the description and an input field for the nickname. It also fixes a bug in the user property service and adds a redirect after saving changes.", "commit": "bee7d828cd67581c33946630cd22fe8edd674d15"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile page and link avatars to user profiles", "commit": "3b05acffd296169eed305a55dba79d632d5f78f5"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Implement user profile view and template", "commit": "a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Refactor user property setting logic using upsert", "commit": "9fb6e64655dff132be43e2fc867827d17ad94201"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Improve user profile rendering and link avatar", "commit": "0fa04883850534fbb97755e06ebec1538dccfdc7"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Use user uid instead of request user uid in user_property.set", "commit": "d4f5a4640929b1f16ccddc741f977cf7e901e7de"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added back button to user page sidebar", "commit": "3cfb79c8f560430639fceb4a278fc81dfbad2299"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Removed fixed positioning from header on smaller screens", "commit": "c36ce17da5fcccfdaaf7ddf7579b0399519d078f"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added chat area class to user profile section", "commit": "4cc70640e4f7c4d65e5a0c3a503aae1f891164d5"}
{"repo": ".", "date": "2025-04-17", "line": "feat: Refactor layout and styling for user profile page", "commit": "1cd0b54656a969bcb87cbdc07687866ca43e650b"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added command-line arguments and executable script", "commit": "46a8b612b49f1094c0a8520d97d4b5642f2a57e9"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Configure setuptools to find packages in src directory", "commit": "061da150f9779c6130fac0c957d4facdd59aa33a"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Handle missing pty on Windows", "commit": "6312dfae47b09753675b038798cf38f00311e772"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added UUID generation and updated hashing functions", "commit": "c709ee11c99f54b58844165b6eb9993240ab0005"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added paste and file drop support for message input", "commit": "f7fda2d2c951a07bccc927a333b7feaa527556c2"}
{"repo": ".", "date": "2025-05-06", "line": "fix: Use user home folder for uploads", "commit": "529ebd23fc0b50e2606ecddc5e1199774dd18384"}
{"repo": ".", "date": "2025-05-06", "line": "Merge feat/copy-paste-drag-drop", "commit": "0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Implemented file paste and drag-and-drop support", "commit": "b0666a00900e1b25633433b80da1ef3dd5f2ee71"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Dispatch upload event and focus input after upload", "commit": "707788583a2387c1729950e08243d3f8f7049d7c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added focus functionality with Escape and G keybindings", "commit": "d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2"}
{"repo": ".", "date": "2025-05-08", "line": "fix: Prevent double focus loss during upload escape key press", "commit": "fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "e153811ff34ef63892cc6aac1d5afd92cb510d14"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "0a3e15137761d333211d8b52d178f5e150a579f1"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus upload while shift+G is pressed", "commit": "f6706c165e2a8ba392bde1e81d8006381fed96d3"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after layout update during upload", "commit": "8799662159656867494b2774073b3bfb1bbe5178"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after upload", "commit": "49ec99ef016dc754e36442d774da1d3a712bf2a7"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated Dockerfiles and terminal configuration\n\nThis commit updates the Dockerfiles and terminal configuration with several improvements:\n\n-   Added `xterm`, `valgrind`, `ack`, `irssi`, and `lynx` to the Ubuntu Dockerfile.\n-   Added Rust toolchain installation to the Ubuntu Dockerfile.\n-   Modified the `r` executable placement in the Ubuntu Dockerfile.\n-   Updated the terminal.html template to include fit addon and clear the screen on connection.\n-   Added `$HOME/bin` to the PATH in the .bashrc file.\n-   Removed the `.rcontext.txt` file.\n", "commit": "3c1d5d601fa1a9f30b2aa4fd36086102108dde94"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated Makefile with venv and ubuntu build target", "commit": "d0dd342e27cf4160f96faf87deff81f728e41e47"}
{"repo": ".", "date": "2025-05-08", "line": "chore: Updated Makefile and .bashrc", "commit": "31062fddbfbbf25f206060b38773a7e2c008723c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Install tmux", "commit": "3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement user template loading based on admin UID", "commit": "02a0253c1d9c73d2918fecb8f52c4c7739c867f5"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Prevent appending None to template paths", "commit": "165dda32100e52c347c7f6bb71062244b2a50ba1"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with user-specific paths", "commit": "ac570d036c26b6c7ff5abac169fdf622c10827ad"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Pass self to static_handler", "commit": "b867b6ba78574a332bf951eb6c00a6a88ded325d"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use request.session instead of self.request.session", "commit": "e359a8ebe294e0f55cf4164926011e893468e4bc"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use user static path for templates", "commit": "5b28044d9e604b2404bf4b7277a240f7fc56032c"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with `add_static`", "commit": "c56bf4fb49c986e9b653f635a81937a3ef433a5e"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository management functionality\n\nThis commit introduces repository management features, including creation, updating, and deletion. It includes new models, services, and views for handling repositories, along with updated templates for a user-friendly interface.  Also added uvloop for aiohttp.", "commit": "ee40c905d4448f0b39d28c4d51343b5fe111d038"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added basic Git repository management functionality", "commit": "a5aac9a33701e3d4852fba13520771e6de82aac0"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Corrected repository deletion URL and implemented repository deletion functionality", "commit": "e06776d81d0fa40e4d9d5f57a6259df8db271372"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Add uvloop dependency and fix repository path resolution", "commit": "adb59eff68e5c855fbce6f930db1ea13f59683f6"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement basic authentication for git receive-pack endpoint", "commit": "3ae30f1f7645203a8e8c15bd298d802fffbd2334"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Increase client max size for uploads", "commit": "e5d155e1249f9df7c504a95c98171f7e4fe5d5a4"}
{"repo": ".", "date": "2025-05-09", "line": "refactor: Migrate from argparse to click and improve application startup", "commit": "7e8ae1632d19238954ca96657da1d3950ebd413c"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository view and related functionality", "commit": "95ad49df432195cb127f9fe695eac14678422b37"}
{"repo": ".", "date": "2025-05-09", "line": "Error code: 422 - {'error': {'message': 'Provider returned error', 'code': 422, 'metadata': {'raw': '{\"detail\":[{\"type\":\"model_attributes_type\",\"loc\":[\"body\",\"response_format\"],\"msg\":\"Input should be a valid dictionary or object to extract fields from\",\"input\":\"json\"}]}', 'provider_name': 'DeepInfra'}}, 'user_id': 'user_2wGl2Zqx0Xrkj5eQdwZSgLEFytg'}", "commit": "17c6124a57a394c63427a0038e598fdb40560f15"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Initial file manager UI and basic repository view", "commit": "4c34d7eda58530eddb2c8b3479627180d6eeb248"}
{"repo": ".", "date": "2025-05-09", "line": "Error code: 422 - {'error': {'message': 'Provider returned error', 'code': 422, 'metadata': {'raw': '{\"detail\":[{\"type\":\"model_attributes_type\",\"loc\":[\"body\",\"response_format\"],\"msg\":\"Input should be a valid dictionary or object to extract fields from\",\"input\":\"json\"}]}', 'provider_name': 'DeepInfra'}}, 'user_id': 'user_2wGl2Zqx0Xrkj5eQdwZSgLEFytg'}", "commit": "1616e4edb97284f705400c0598306202a083f60f"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Update dependencies and refactor repository view for improved navigation and file handling", "commit": "44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added DBService and RPCView db methods", "commit": "dd108c20044540c3801ac461c612392bed76ff89"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added Drive API and HTML views for file management", "commit": "f0591d493955d9c126e7dee6d1a06917c48bbbd2"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Implemented typing indicator and glow effect for active users", "commit": "3412aa0bf0c0bb138c234f88fad55cb69267df79"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added channel attachment functionality with file uploads and views", "commit": "9133b7c3ce6457fa6c218b540828c752b4ba5c72"}
{"repo": ".", "date": "2025-05-10", "line": "fix: Remove debug print statement in SocketService", "commit": "4d7566de9bb3f2c54954fe72d0332caecd133ffa"}
{"repo": ".", "date": "2025-05-10", "line": "refactor: Remove hardcoded repo and user configurations", "commit": "2c9004418555dfc2a4c826e5c30aa0d59f332df7"}
{"repo": ".", "date": "2025-05-11", "line": "feat: Added SSH server functionality with user-specific home directories and authentication.", "commit": "01846bf23f7883007b99a2e100240bf3b35b30f2"}
{"repo": ".", "date": "2025-05-11", "line": "fix: Updated SSH port to 2242", "commit": "c48b84bf3ab7cff5dee5670e23db3d771e14fc46"}
{"repo": ".", "date": "2025-05-12", "line": "feat: Add image conversion and resizing support in channel attachments", "commit": "f156a153de1b2f89b99cf0490eb18bf27a611fe1"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added image conversion and resizing support for channel attachments", "commit": "ac2f68f93fd66c0d6ab3682525b2d9c94febff4d"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Improve Windows compatibility and database initialization.", "commit": "a4bea9449526fc8f6b01c02d777db5c30186b830"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added WebSocket synchronization and testing scripts", "commit": "ba3152f553afcfae318811a413cdea6f5be9f413"}
{"repo": ".", "date": "2025-05-13", "line": "refactor: Moved WebSocketClient to system/websocket.py", "commit": "adad5ed4fe37038442d247d9246795a82d31093c"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Remove timing output from task execution", "commit": "2e324ff11815d3c67fffa8e8d5f3e3554f154b57"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Implement image click to view full size", "commit": "d09055986e9a5d971f58075a5e939a268deb26be"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Add width parameter to image URLs", "commit": "8cd2f16c5c46318cb035b882197b516ae5532452"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added width attribute to image source", "commit": "015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected image width attribute in template rendering", "commit": "12d287042415554c581e7d4fcfc81bd3d733fa02"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image source", "commit": "964a747f42ade75dcfced5395f46727f0508172c"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image sources", "commit": "319c1b1b5264933a7ea1d7af6541be2a410a3328"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected webp image type in template", "commit": "a21e3590ef4ddad292fb914cb3454d07eb622413"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Reduce image width and fix image URL in web template", "commit": "b55d74fb124b90ee24d158bc94c401b0ff19edb9"}
{"repo": ".", "date": "2025-05-13", "line": "style: Added cursor pointer to chat message images", "commit": "3858dcbd62e4032a02e9d25dffd000ade4dc7bbe"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add height parameter to image rendering", "commit": "0ea0cd96dbe536b09cb3549de47766a756f04008"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Encode attachment URLs for safe transmission", "commit": "af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected attachment URL and added scroll to drive view", "commit": "c45b61681dc2fc2a239e1bc2672da44f7738b0c6"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Introduce online user list and typing indicator", "commit": "db6d6c0106267f56822ae378a4c88385d025051a"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Updated message text updating and added message age check.", "commit": "25d109beedf030523ac5d357dbbb1f9efb919edb"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Implemented dialogs for online users and help, and updated templates", "commit": "dd80f3732b7f500acdd92f6e44f42f9ade0f205b"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added /live command to help dialog", "commit": "79c39828f0a3282f53e3322e19b211b5559466a1"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Implement user availability service and update logic", "commit": "c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Update socket service and attachment view\n\n- Added last_ping to user data on socket connection.\n- Fixed bug in attachment view where format was not correctly passed to image.save.\n- Improved attachment view to handle multiple attachments.", "commit": "93462d4c4b93c4f7eb81702801df9159cbb64e8e"}
{"repo": ".", "date": "2025-05-16", "line": "fix: Increased last ping cooldown to 180 seconds", "commit": "c387225a6e8aa826b944ff3c53c4045db25db758"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Added cache enable/disable functionality", "commit": "00557ec9eaab7256d5c129fc8c00c12650ea3fd3"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor index.html with improved styling and content", "commit": "c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor chat input component with improved auto-completion and live typing functionality", "commit": "48c3daf3983e3b6e04a0c5888febceb69db9d661"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Added star animation to sandbox page", "commit": "e79abf4a26454cddf766cd1ba138554817c820cb"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic structure and boilerplate files", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with basic description and setup instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added basic login and register pages with styling.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0"}
{"repo": ".", "date": "2025-01-18", "line": "docs(docker): Add restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and introduce generic form components.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Complete system with basic functionality and initial views", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button navigation", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated button URLs in index.html", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and layout improvements for responsive design", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved aesthetics and responsiveness", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor login and register form endpoints to use /login.json and /register.json", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Switch to gunicorn and add docs and about pages", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added API documentation with code examples and HTML template", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog element", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field.", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Add documentation subapp and markdown extension", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation and basic API examples", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor session management and improve status endpoint", "commit": "12ca8e4296ca9693276422e524d7061685556ba0"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and mapper setup, introduce Cache and Object\n\nThis commit refactors the service and mapper setup to utilize a more structured approach with SimpleNamespace and Object. It also introduces a Cache class for caching frequently accessed data and a BaseObject class for managing object attributes. Additionally, new mappers and models for channels, channel members, and channel messages have been added to support the new features.", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor model and service imports for clarity", "commit": "f25feeeca3502eee94554e7152ca7ca946115053"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and WebSocket support for real-time communication.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df"}
{"repo": ".", "date": "2025-01-26", "line": "refactor: Minor code formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Use dynamic websocket URL based on environment", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improve WebSocket connection handling and UI\n\nThis commit introduces several improvements:\n\n-   Added error handling to WebSocket send operations in `SocketService`.\n-   Updated the WebSocket URL in `app.js` to include the port number.\n-   Implemented `query` methods in `BaseMapper` and `BaseService` for database queries.\n-   Added a `chat-window` component and updated the HTML structure for a better chat interface.\n-   Implemented `get_messages` method in `RPCView` to fetch messages from the database.\n-   Added `get_channels` method in `RPCView` to fetch channels.", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and configured database path", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added notification service and related mappings and services.", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Implement chat input component with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove unnecessary console statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Removed console logs and initial message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic files and structure", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with basic description and setup instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added basic login and register pages with styling.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0"}
{"repo": ".", "date": "2025-01-18", "line": "docs: Added restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and introduce generic form components.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Complete system with basic functionality and initial views", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button navigation", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated login and register button URLs", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and layout improvements for responsive design", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved aesthetics and responsiveness", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor routing and form endpoints to use /login.json and /register.json", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Migrated to Gunicorn and added documentation pages", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added API documentation with code examples and HTML template", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog element", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field.", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Add documentation subapp and markdown extension", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation and initial form API examples", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor session management and improve status endpoint", "commit": "12ca8e4296ca9693276422e524d7061685556ba0"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and mapper setup, introduce Cache and Object\n\nThis commit refactors the service and mapper setup to use a SimpleNamespace for better organization and introduces a Cache class for caching data. It also introduces an Object class for handling data structures.\n\nThe changes include:\n\n-   Using SimpleNamespace for services and mappers\n-   Implementing a Cache class with caching functionality\n-   Introducing an Object class for data structures\n-   Updating the application setup to use the new classes\n-   Adding new models and mappers for channels, channel members, and channel messages\n-   Refactoring the user service to use the new cache and object classes\n-   Updating the login and register views to use the new service and form classes", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor model and service imports for clarity and consistency", "commit": "f25feeeca3502eee94554e7152ca7ca946115053"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and WebSocket support for real-time communication.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df"}
{"repo": ".", "date": "2025-01-26", "line": "fix: Minor formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Use dynamic websocket URL based on environment", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improve WebSocket connection handling and UI\n\nThis commit introduces several improvements:\n\n-   Added error handling to WebSocket send operations in `SocketService`.\n-   Updated the WebSocket URL in `app.js` to use port 8081 for local development.\n-   Implemented a `query` method in `BaseMapper` and `BaseService` for database queries.\n-   Added a `chat-window` component and updated the HTML structure for a more modern chat interface.\n-   Implemented `get_messages` method in `RPCView` to fetch messages from the database.\n", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and configured database path", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Add notification service and related mappings and services", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Implement chat input component with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove unnecessary console statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Removed console logs and initial message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove unnecessary benchmark script", "commit": "374db23669e203c98e5335b9a7abe9aff2110537"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improved cache logging and socket cleanup", "commit": "f3d12a257e7a43e3292654d7f67f05d823f16283"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove generated pycache file", "commit": "8e825a90c6e575f114b380312bb9c5726577b8b7"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit table results to 30", "commit": "01d8093e7210910016ea5d6d8bbc5d8f2514c14d"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit messages to 30 in RPCView", "commit": "d93d48ef7e023c62bfa9b64ede20cd9f86c3242e"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Improved message handling and added scheduling for event dispatch", "commit": "da72a15068fe14eeb2b50b4cd3342fb4b70b0c79"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule functionality and minor UI adjustments", "commit": "99d335ac244c2258d82821344fa517857a782f4a"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule benchmarking with custom messages", "commit": "4f1a48c197fcad25d80873bac55cf66f7ff99382"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound on new message", "commit": "5aee606d5d65e71afa8366d24ed4632f662a9126"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound and improved chat input functionality", "commit": "14c59ba5c0abc7d1331e022cc99222223ea21526"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Improved connection handling and PWA support", "commit": "b2ca373081bdd7514b0f849dc1033edfd3f76424"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added favicon and manifest for PWA support", "commit": "7d05bd9da45489c02a9b057eef86d45e2ca90049"}
{"repo": ".", "date": "2025-01-28", "line": "docs: Added display and start_url to manifest", "commit": "4da635502bca60efd0cc59aa4df236d7b99c2ec2"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Reduced padding in chat messages", "commit": "d69c75c6197e857ad61e4dbc872b5ab5872c4837"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Add data attributes to message elements", "commit": "9e94210bc3f3b1b614a198591c52f404d84a8be2"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added user color and updated message display", "commit": "84e5bac1b93d5d1c124d303e6b08a29baaf4977c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add utility service for generating random hex colors", "commit": "284d38096c7c5b1201f261ec7a5a28ed457952b5"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added avatar color and text color", "commit": "93b2f6cc41f08e21241642976b90e3dd98dc37ec"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add linkify functionality to message text", "commit": "9f652ece1bf0498f9032f94b77becc96b6eff009"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Increased result limit to 60", "commit": "16afbb4e15f370babeedfc2aa917daa0292da5a6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide avatar and author until switch-user message", "commit": "41927b7ef439424326cc58e3939f476e04b8eabb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and switch-user class for message differentiation", "commit": "75ec590be5fc3f446c97549d90c135966142ac25"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Show message time on last message and switch user messages", "commit": "0e821f8b588def99f950fecb9369456cff086e0b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Ensure HTTPS for external URLs", "commit": "931aae5134cad80bf7f5ba87fe215a03761f081b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added html-frame.js script tag", "commit": "c558dc2d79b90e7424cf4311747f077332b0a193"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected typo in script source path", "commit": "5f3dac8bc6b702735383688de44ad7609264742a"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Ensure URL is HTTPS and handle relative URLs", "commit": "4442f75ec50d3d27cfae1702459d5f8f34ba415b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added padding and URL handling for HTMLFrame", "commit": "030942db0984ac0f3a4072581d58d81fad03ef91"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install prompt and button for PWA installation", "commit": "438fad301447e3265ff7484606f8222b271e4d9d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to navigation", "commit": "3e4b6b00620f8cf2c8b8c63918e6c93d2987174d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide install button on initial load", "commit": "1f5dc57d6f24b17fa66ab5692038e005cc444378"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render messages with HTML for rich formatting", "commit": "03c72e85f72207a7b2480f881f7b0cb7055c5feb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added basic message template with markdown support", "commit": "561a915e30274d8b191678135912313ebccde70f"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project to snekbak", "commit": "d7c003c4096f8cfed8f4edd517f41d45f4f8b501"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project from snekbak to snek", "commit": "f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added documentation and initial form API support", "commit": "b562d171674c2f75592ff3a0dd25b51d2a2457db"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added cache to .gitignore", "commit": "82de0f304469e6214169a2bdcf9c65673baa9e76"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added docks to message template", "commit": "80f1bbc05e612c45fb2ccbb629a6aa3b468c627e"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added snek image", "commit": "9e89e27c6688b0e05e4a10a0538d599f82278e64"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added notification audio and scheduling functionality", "commit": "af399e3b72c772ed97e943e7d71dc6384ab8ccc0"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added Docker configuration for deployment", "commit": "3be25285f4f0afaaf991ee7cc0a8f71854e8de4c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Enable markdown rendering for messages", "commit": "75cb7605cd5e8e91cab2ffbc9000eb5987e40136"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render markdown messages with raw HTML support", "commit": "4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5"}
{"repo": ".", "date": "2025-01-29", "line": "chore: Remove generated .pyc file", "commit": "f69586ccf7975be0bdd24659d6acec068f5183d6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added requests dependency and template extensions for linkification and python execution.", "commit": "bca39a612cad5f340864a4dc62d94cda962985f9"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Style message images and add time ago functionality", "commit": "5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Improve time display and linkify URLs in messages", "commit": "20d8d27f03e87bf06515d0664a00e669b92df49f"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Prevent linkify_https from processing non-https text", "commit": "3d6e1d2e943baabaf0b0875284bf18132bc3967a"}
{"repo": ".", "date": "2025-01-29", "line": "refactor: Removed unused padding rule in base.css", "commit": "5c4c5793899776e5d369f3949b4a8142a68ba7ee"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Display install button inline-block", "commit": "a8e3ad1af9f683ad25730ff48180c5306f72e1f6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added username to chat messages", "commit": "99cea506de5ea0c5b373869b7c28d965b8af55e6"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected calculation in timeAgo function", "commit": "03e90039695abc0fdc9276980bd8728bd8951f05"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added SSH service and basic gallery styling", "commit": "b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Initialized SFTP server with basic authentication and file serving.", "commit": "15de277a5be330fe6962e5271c537e3b5ef40de4"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Enable unbuffered python output", "commit": "8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for response messages", "commit": "4de93489ef01bf070f461915989be611156121dd"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Improve socket error logging and flushing", "commit": "1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for incoming websocket messages", "commit": "312b9eeecaee5d16247a7f0c694e3168893c389d"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Simplify error handling and remove session-dependent code", "commit": "c6f43931664c01c597c642e35c64ec49f3008101"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Remove debug print statement", "commit": "5fd03efc301d722a5ee09c8b0cef6d04c1130fd3"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Handle WebSocket close events and improve error handling", "commit": "780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Added logging for RPC exceptions", "commit": "cc3b896d2cd80affc251434800844829cc3fb6e1"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Use internal send method for RPC responses", "commit": "0a70e80668a598c909674c78b654b5ad8e6afce5"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Buffered RPCView methods", "commit": "bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Use `send_str` instead of `send_text` for JSON serialization", "commit": "010f3b03a0983843c74219484e78c50d595da6e7"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Import json for RPC handling", "commit": "8f502af84eea60b5349fd1980d352f0f8e001502"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Ensure messages are flushed to console", "commit": "10c7232a8f6378eed8f5b4adecca8d582d57a069"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent premature WebSocket deletion", "commit": "88749ce05c7c4e9b5e238d16cb9fa4053f092fc1"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Enable websocket subscriptions after login", "commit": "2ae2e8450cad47031067f3baae3b09ff521c5c87"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Removed unnecessary style block in message template", "commit": "495543144d464121af0afab6545a5267ad561a57"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added highlight styles to web.html", "commit": "cfd3e7881eca77d10d32de2440a9d2b03aeaea96"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Streamlined message template rendering", "commit": "efe12644eda127170a3d60e086fa31ed940fca6e"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent default event on keyup and change events", "commit": "7526bcc816ffb759e3708f30167b4d3367955b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent form submission on Shift+Enter in chat input", "commit": "1999a6c8d8dd4fdbd48d5553a1704dfa065275ee"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent empty messages from being submitted", "commit": "ae5fffe5e0faf948a22feea0e651e08a0ed559fb"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent submitting empty messages", "commit": "663ab415101e5da31fb71e3e9e3b433fbd6c3031"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added query endpoint with security checks", "commit": "3796c7c54767b5de18c5310d20c9dd3c5aafdd0c"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Made channel query asynchronous", "commit": "f6f99684307249b6650dcbbb3168db1ebfa71e73"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Disable autoescape before linkify and markdown", "commit": "0c68c4e62255a307ecb48cba011ef38ace935eb3"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Allow underscores and plus signs in usernames", "commit": "4185bb3a69ac66d7b6614acf76bb5a2f613e0b82"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added emoji support to templates and app.py", "commit": "928969b8b6266298317ea4f7ca3e6b2cfbd42e82"}
{"repo": ".", "date": "2025-02-01", "line": "refactor: Removed unnecessary Z suffix from ISO date strings", "commit": "feeb94c9cf08ebee6d42165988b1d51030df4c33"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added highlight stylesheet link", "commit": "e0ed4491b414c51b54e4c3ebd10cbebb46a903c6"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Add basic syntax highlighting CSS", "commit": "98d89dbc5f45a61ab6335e38d6e4a1df39bcc621"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Applied highlight.css to message templates", "commit": "a06e3f404a15d8115fa65ba8533ff7774baa0beb"}
{"repo": ".", "date": "2025-02-02", "line": "feat: Add user data and audio notification check", "commit": "99fc9118b37f8564cd6e211d3d77ef997592f361"}
{"repo": ".", "date": "2025-02-02", "line": "fix: Resolve issue with user data loading and notification handling", "commit": "7d750db1f8235c8231699c2da39c1075ac678841"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Improved code display with word wrapping and line breaks", "commit": "3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in base.css", "commit": "23c8ebca73ac49c826434d40fd1e1fd2e3435957"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in chat messages", "commit": "38a24e9a12355f93776c9aef0b9caa5afb075531"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Commented out padding in message list for testing", "commit": "83cc0f613708ed27b928ba45b330c984d82dd546"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added padding to message list and hid avatar", "commit": "079187e1b460e5554bfec8b9658b5059cc3d51c6"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Hide avatar on message list", "commit": "f4a5536dcf1e27a7e8319488f8f39a8acfb818a2"}
{"repo": ".", "date": "2025-02-03", "line": "revert: Restored avatar visibility", "commit": "fe707dca4ea0bc2ecaedcda292f1ae636fce2b93"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added upload button functionality to the chat interface.", "commit": "b48a901e3385617d36511e251c4e7c62498e23bc"}
{"repo": ".", "date": "2025-02-03", "line": "fix: Remove unnecessary whitespace and improve text wrapping", "commit": "f395d1617394045cac7c41af0cd5ce9d6ef55ed8"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed setup.cfg and adjusted code for improved stability", "commit": "084f8dba2075aec93d9d88fd7cdd7f67fc63a212"}
{"repo": ".", "date": "2025-02-04", "line": "feat: Added drive service and upload functionality", "commit": "6f9adfe67fd551dd99746c40bb55706a7ffcef3b"}
{"repo": ".", "date": "2025-02-05", "line": "feat: Added :snek1: emoji support", "commit": "b6185a95f3fcbf539ec0ba767d4c0923092f8e82"}
{"repo": ".", "date": "2025-02-06", "line": "fix: Handle failed RPC calls and track success status", "commit": "203314b209030f297cd888685bb68721bc21c61b"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Correctly detect code language or fallback to bash", "commit": "386d9c3aaee80115241866ae72df9fad3ea3c714"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Handle empty code highlighting results", "commit": "d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Escape code blocks in markdown renderer", "commit": "a301e2c5bfb8286f63a48c2860162780f95e820d"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Updated html escaping import", "commit": "cfa2af61b81b613bf2b8177b8acff1ea8b7c8576"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Added language detection and code highlighting for markdown blocks", "commit": "51f1b1d86e4813c10e2750f0771c1bdcc1274bfb"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Replaced navigation links with emojis", "commit": "9840c8eb03f969330583e9c3a7b28dbb5548f7d6"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Prevent lingering websocket connections on disconnect", "commit": "7ca2bc5776213828a31c7fc237784a0a73c6f759"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Allow underscores in usernames and add user search functionality", "commit": "f291c0f2e4081fde4ed55d6cc25fdcbb1952af70"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Initial app template with basic structure and links", "commit": "fcb05903f3f583ce8532d65ee7edf1ad8df91df4"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Relaxed username and password regex constraints", "commit": "8d0d709e18be0177b99f76f320eeb02b70bb41b0"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Added user search functionality with HTML and API endpoint", "commit": "d7b943dc8c8f485c975730d6054e32e67db36c91"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Implemented search user form", "commit": "49eb76dc8b93cd422a9fda40cece480a573b8524"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Prevent malicious queries by adding more checks", "commit": "60ca3ec7918073a2fb3ebe81e9ea733225391d99"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Style updates and search user form improvements", "commit": "5154811b29ced87375ac457fafeb25305f64a954"}
{"repo": ".", "date": "2025-02-08", "line": "style: Adjusted chat message container styling", "commit": "a8fea31a326c7b9868dde866be553bb9f84eee88"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Optimize database performance with WAL mode", "commit": "06b539b8845c49ec6d2789876ef4069b8df77117"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection in UI elements", "commit": "b169fa4792e02303ea0f61e5d83b3993a8f72f05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection on header elements", "commit": "ad4847a78e2945fe4f57ae16262e0c2a91a804f5"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent navigation selection", "commit": "bda5cfd52d5272742d147e93b506b87eeee04e1e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor search user view and template name", "commit": "afa40ada778c5d4102bce19312456adca51f70d0"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Implemented user search functionality with basic UI", "commit": "a42c2bdf5d2cee53c16e8dc123fc4473107ef203"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent SQL injection by enhancing query validation", "commit": "e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added branding and updated view templates", "commit": "a3cec5bce0386c8c3012262aa4a582241786220d"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added WebView and channel routing for web interface", "commit": "78f9679f308016320b64cd49bb3552fb63d26d27"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added time descriptions and user switching in chat window", "commit": "e7cd397e0fe98074833e08880d915516718adaf5"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added custom scrollbar styling to chat messages", "commit": "feb5234b3b581936d45ac328b23de7da8f375ee2"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Improved chat message rendering and layout updates", "commit": "ecb77cf361f0d55b512028701c67c1e347836e6e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved layout updates after message insertion", "commit": "bfca2bdf734c9b9522186c1ff1b6479f93f34658"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor CSS for improved layout and styling", "commit": "83121f7fa99df690a3b9029556aa023226cf22ef"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Correctly append message element to chat messages", "commit": "dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improve text wrapping and code highlighting in messages", "commit": "cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message appending to chat messages", "commit": "a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved message content styling and hyphenation", "commit": "661eba7161c1d869d73f04641878521fbdf8b72a"}
{"repo": ".", "date": "2025-02-09", "line": "style: Removed unnecessary block display from message content.", "commit": "e75836fe879e4f7e2a8bb34ed8ca901cc624ce05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message time display", "commit": "0f400a0b6aa04ffdfd8de1e26be3318a39a174a8"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Enable CORS credentials", "commit": "49c0f932ab3e5705380a57cefa8da9ea7b9967d3"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implemented online status and ping functionality", "commit": "54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Display online status for channel members", "commit": "688e7fbf0e8977f442edc41cea1ac2a06f1ece40"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Increase online status timeout to 20 seconds", "commit": "48891c438694d37cd1b8338a2cc1f96f7647e77d"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Include last ping in online user data", "commit": "087ab1a8a55ae58b52078dc5cb7de7db65132e84"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implement online user status indicator", "commit": "3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added private chat functionality with DM creation", "commit": "8a59ddd210bb3ab3d29f9207afbf887988b528d9"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "ca463b79a88687a76d9fab851b8f2ffc7e071e81"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and updated tag to lowercase", "commit": "8fe24f711cb3e796471145a086c4b72289e12e1a"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and redirection", "commit": "bfe4b351c1fa750c9d12d3ae880928cca0346bba"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "be35a6caf07c51eaf79625ad914215bebb9b11c5"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added base URL property and file type handling for uploads", "commit": "2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Display uploaded files as links instead of iframes", "commit": "2541fc536aa45630ec55297c58787686e4154fab"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added echo endpoint and noresponse handling", "commit": "b6eba608435be4d798e50328f9149a8768a5cc8e"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and updated templates", "commit": "3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Add channel list and improve DM user display", "commit": "37da903936e4ab85fae254421c356966991d53e4"}
{"repo": ".", "date": "2025-02-15", "line": "```\nrefactor: Improved socket communication and removed unnecessary prints\n```", "commit": "1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Refactor socket handling and messaging for improved user management and broadcasting", "commit": "53be4b060a1fff9cf58c7224dc4522bb0cafa852"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Add channel tag to RPC view data", "commit": "9c3abdec2613c4d492c363cca8a07882dd3d8135"}
{"repo": ".", "date": "2025-02-15", "line": "fix: Correct channel UID retrieval in RPCView", "commit": "d1396801c05688e15ad7f1082dab2576b9a2b011"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed YouTube videos directly into links", "commit": "7c4334fe7b5b7e6ba44627a4f084638af51dd44c"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed media, images, and YouTube videos in links", "commit": "c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Correctly linkify HTTPS URLs", "commit": "263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc"}
{"repo": ".", "date": "2025-02-16", "line": "refactor: Remove unused YouTube embed code", "commit": "7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Handle file extensions in upload URLs", "commit": "be956a13db0941008802701196ca5e3870ebf2aa"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Ensure images, videos, and iframes within messages scale properly", "commit": "ea4196af8f7d7e0c97c004a07817bdc1dac999f5"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improved media handling in message content", "commit": "f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement scroll to bottom on new message", "commit": "2e69ac5921c16ea0cda1a1b7c84dd63ff458db62"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement timestamped pagination and focus textbox.", "commit": "8c33bc63d6cc623d0782f14c96a42c612accbc75"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "477ca5917a59d3720a6a5ae01b307dec1a74cfaf"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "9e3b9ae326b6ec632f3280018ea68d1896645b9a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use offsetMessage timestamp for infinite scroll", "commit": "33bc695cda6bf5889d802129117ed59992b87143"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected method name for fetching messages", "commit": "aa5703e62f891fa7db09f07e6ce060875f5990d3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected offset calculation for infinite scroll", "commit": "1792686531fb440d9f453422bfa648c890c255d1"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll offset for infinite loading", "commit": "3ee7c6d8024245933569fdaf3f99a71afd14fe8f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "95a8a458420dd2ebdbd6f7c03bd58c649985933e"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "162f89f9d0f7e304d355dd8f626ce2430dd840bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use first message timestamp for infinite scroll", "commit": "104ee277669ee2cd55eb97b674f7a2432d31bb5a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages when not scrolled past half", "commit": "60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling based on scroll position", "commit": "2fb6be753efa7f2cc1e0183551a1ad655388b970"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "24cd378c9d8f857f4f28af1069a2f5523a6441d8"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use scroll instead of auto for chat messages", "commit": "8b98935d11496d51a0007db4b78391dde7a69163"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar and improve chat styling", "commit": "5b03ecda3f3f9ae515dd00a4e421255535a2f215"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "3230c9f93bf9c3ae1b27474eac1cfc35f626d387"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improve infinite scroll trigger position", "commit": "6c58f4b26c628881aee5cbe0597ba489f705e42f"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust scroll trigger for infinite scrolling", "commit": "bc8a296223f3a2c6e07b126a78373aa5bb40399d"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll position threshold for infinite scrolling", "commit": "c77d2fb782258f787c5b52e3b27c5a3b0d468903"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll threshold for infinite scrolling", "commit": "2e86ca2a3f1a4a8c746eecb46038a216b9706cdf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Disable unnecessary scroll prevention", "commit": "1a608d8cfb1a3dcef9591b214fc54904615148bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Enable infinite scroll when near bottom", "commit": "f0d76bd46af06637a21526c58d97f4b4d57f87dd"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Disable scroll loading for now", "commit": "1b6ebf50080b0b86256e639031f36da92b8990b2"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correctly trigger infinite scroll when scrolled past halfway", "commit": "6bdc6a7347a492f155629458c9b277cc16e04666"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected infinite scroll logic", "commit": "c042af8b800879ecfbb817089119aab75d839c32"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected initial message retrieval for infinite scroll", "commit": "2595594c3a99f6613e8e2194977fb1707c9f8b98"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Added comments and improved message loading logic", "commit": "6555e4f8266b01963cfd660a4e175b01ab615c0c"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Remove unnecessary comments from web.html", "commit": "2ab4341d0099799a84c0df6d91de33e1c5f69470"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Update manifest and app.html for PWA support", "commit": "e21880b4f5fd15259e09c207ce54d8e86bd61ac7"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Remove unnecessary display_override from manifest", "commit": "6c7266f20403f1f190c8b41f22d653c041dbbc77"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Added 192x192 icon to manifest", "commit": "c745f609976397de0fb0cf7dec80205239b44b87"}
{"repo": ".", "date": "2025-02-18", "line": "refactor: Switch to asyncio for application startup and debugging", "commit": "3ccbe8be5c604d2683cf55553d1f11c674f6b930"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Standardize environment variables in compose.yml and add logging to app.py", "commit": "ebb520dd4a80b513d1eb6fc6ce90e6b46f905100"}
{"repo": ".", "date": "2025-02-18", "line": "feat: Integrated profiler for performance analysis", "commit": "c6620ad70afce9407c16793de8ab4fea35523d81"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Sort profiler stats by query parameter", "commit": "91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Corrected typo in profiler sorting", "commit": "60404c6fd31894f3fbb6ce31ba48f1750101748f"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Added a1 emoji and long emoji\n", "commit": "736123c4aa313c51a7e0daee8cdd6dc7583547fd"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Increased Gunicorn workers for improved performance", "commit": "2ad5a7b1f49704baf7b890fcfda7a87fddd456f7"}
{"repo": ".", "date": "2025-02-19", "line": "fix: Reduced Gunicorn workers to 1", "commit": "e06824f4ec703388b7d55beeb5f1b3ef12452226"}
{"repo": ".", "date": "2025-02-19", "line": "refactor: Increased Gunicorn workers and moved app instantiation to global scope", "commit": "821db3cb1a67c20a968ac1dd8ecc4263e511cf16"}
{"repo": ".", "date": "2025-02-20", "line": "feat: Improved database indexing and UI enhancements\n\nThis commit introduces database indexing for improved query performance and several UI enhancements:\n\n- Added indexes to `user`, `channel_member`, and `channel_message` tables.\n- Updated CSS to include a container with improved styling for lists and links.\n- Modified `manifest.json` to set the scope to `/`.\n- Refactored `template.py` to handle image embedding and YouTube links more robustly.\n- Adjusted `app.html` to display \"Channels\" instead of \"Chat Rooms\".\n- Enhanced `search_user.html` with a container and improved styling.\n- Sanitized user data in `rpc.py` to remove sensitive information like email, password, message, and html.\n", "commit": "3623286a9dfba330612c42e579abcca63ab186ed"}
{"repo": ".", "date": "2025-02-20", "line": "refactor: Reduced gunicorn workers in compose file", "commit": "a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Added sound notifications and mention sound effect", "commit": "54920e1545ffc68e2f928d3d042f5f11080f0d41"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Implement notification sounds for different events", "commit": "8ea41bb592b86e2f49b2f838e03006bc04472da5"}
{"repo": ".", "date": "2025-02-22", "line": "refactor: Moved sidebar channels to separate template and added channel notification", "commit": "fbe95d6631dfac2edc4c8600922020be4e15eccb"}
{"repo": ".", "date": "2025-02-22", "line": "feat: Add channel sidebar with message counts and highlighting", "commit": "076fbb30fb51ecfb5b15d394c760f55dac26e1c1"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Added avatar support and updated message template", "commit": "5af4e5754b6902ae13c798d9793281d62b684590"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatar UIDs", "commit": "e280e8776457605bbb5548fe9de5328b7b04bb8a"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatars", "commit": "162cfe394558a085894a11cea57b193d6108b90e"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Updated welcome message and registration buttons", "commit": "da1be6301c79cf76383e6568f91ee23bdf5119f6"}
{"repo": ".", "date": "2025-02-28", "line": "fix: Disable login requirement for avatar view", "commit": "66b85d146abac25df83edc1975db209b9d43fae7"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add last_message_on to ChannelModel and update on message send", "commit": "4620ebb800b5dd848ec28713f1afa20416698922"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add index creation with error handling", "commit": "e469e27abfedc0b08b483e0715b4dc9b16240c5e"}
{"repo": ".", "date": "2025-03-03", "line": "fix: Correctly format upload links and strip trailing commas from link targets", "commit": "45e3239cc06cdab0a8e5c1c1ef56593f65e750ea"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement push notifications with service worker", "commit": "c3c94461c295ef4c6219051369472f983267437c"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add notification read functionality and unread stats", "commit": "578c182f2707a5f5b7c93f421e2035f7271aa60c"}
{"repo": ".", "date": "2025-03-05", "line": "fix: Handle missing notification model in mark_as_read", "commit": "580ec5ab0d57a542ab38b25a6c508804d5bcfa21"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add new_count to ChannelMember and increment on notification", "commit": "84d7b11f24b37cdc41ce9d5bb24be4080af14be9"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display new notification count in RPC view", "commit": "d7851e645785fd707a7c7fdc5b6fff036e0c80f7"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "e1324e99bf06018a804a1a3dcc83f96cde04b1af"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "afbf53938bd59e1e03dfc011063b27134dd0c054"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added new_count to notifications", "commit": "8d3d7327d777ae3150ecaa237e137f8221310dfa"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Remove unused new_count field from notification model", "commit": "4df6055566d61c769ae1759d81900d138093136e"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment notification count and log insertion", "commit": "dd11c8da5acc5ed97a278a705e7282edc7d50bc5"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add logging for notification and mapper updates", "commit": "8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Allow channel members to be created", "commit": "30fe0bfae7f2bc9badcb16f734655e661fe976ad"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Return model directly from query", "commit": "edb35b57070a5659e8491a967880d816f2d07697"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display username when inserting notification", "commit": "0613f6f54de9247c17b4bae924197ffb4cbd2966"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Disable banned, muted, and deleted checks in channel member query", "commit": "1807cff67d3a4306f122d7df4436cc88137f299c"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Implemented threads view with basic message display", "commit": "5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Added threads view and related model/service updates", "commit": "a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Prevent race condition when reconnecting socket", "commit": "e3afc1ba6e97378688027a60d6d98cc19a519a8c"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected avatar styling in threads template", "commit": "37f6725f2f7e36ec03416f191c9d16cd864991ea"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Display user avatars in threads", "commit": "095be5892db198d0a6356c8700ed0c038e419a29"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread avatar visibility", "commit": "9292e3b8f3b64084d6bcc0b13dd42d015f4799d9"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve thread display and opacity", "commit": "24260f9c371ab2d989441e391f513f6460eaa1ec"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Unified styling for chat messages and threads", "commit": "1b72063a5b972dd726c647b7397f0ced16bd66c2"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved thread display and DM name handling\n\nThis update enhances thread display by adding a name and color, and fixes a bug in DM name retrieval. It also refactors the code for better readability and efficiency.", "commit": "5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve message display with rounded corners and avatar visibility", "commit": "98c2213a862b253f8f967f428b0b248bbe3a32f7"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved user search display with thread-like layout and real-time updates", "commit": "0a9b66d2f76a2c4418db7149b17729bf8a2dc811"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Set default page titles and update login/register titles", "commit": "6ecd356cc08e17596ff6b5007c46def2bc17c851"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improved form submission and change event handling", "commit": "804556b74d8caa5e3a79a03cd1a8d7870843b898"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added meta information to base.html", "commit": "7c22a70722db3fa97a813c30c01c4cd5462138eb"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort threads by last message timestamp", "commit": "8e195a49e3e914a4b241e95378bd9a07611715a8"}
{"repo": ".", "date": "2025-03-08", "line": "style: Improved form input focus and placeholder visibility", "commit": "c9113ca09500c5b3cc277fb09b9607a505d39f30"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added head block for custom HTML head content", "commit": "62aa15a4b4d6514824378cca73084c9ce2df903b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented back button styling and layout", "commit": "ad7eab9717848584369ded6e19babb6a7b9f5b98"}
{"repo": ".", "date": "2025-03-08", "line": "Revert: Undid auto formatting", "commit": "e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0"}
{"repo": ".", "date": "2025-03-08", "line": "Merge main", "commit": "dd5a9a23e8e452a03f7080656608617309bf73a5"}
{"repo": ".", "date": "2025-03-08", "line": "Merge: Tweaks for login/registration and base + image roundness", "commit": "aedfe9aa947dcd2262c825af5a4d977eb298ccb5"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort channels by last message time", "commit": "a219ce4d79a15ef900583ab025fb0da1df79ace3"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Filter channels by privacy in sidebar", "commit": "d6061cb45b68a7b393b0e35a560d5d7bea4b9478"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message, handling null values", "commit": "24a504e3a7383c7a338fbe3ee09411547eed58eb"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message time, handling null values", "commit": "11b8f0e744fb9d6b05ce11b7475bb3f51edee96b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented form preloading and autofocus on the first input element for login and register pages", "commit": "0266b2a559952d0ff767b251c2921704a6aa1abe"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected semicolon in loadForm call", "commit": "fd07001983fc3d3015ac7064461c14b8486155e6"}
{"repo": ".", "date": "2025-03-09", "line": "feat: Preload form and autofocus first input", "commit": "d9ac1813ba8ddad9fb602730cb2cc763aab4bc23"}
{"repo": ".", "date": "2025-03-09", "line": "fix: Sort threads by last message, handling missing timestamps", "commit": "91d8f3efd16431fe99b0e60927d1f6d9b6587f7e"}
{"repo": ".", "date": "2025-03-10", "line": "fix: Improved search user page layout", "commit": "c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8"}
{"repo": ".", "date": "2025-03-11", "line": "feat: Improve file download experience with suggested filename", "commit": "c6c2766381f75b058fb61f91556788b0720b058b"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added reply functionality and improved time display", "commit": "5cfcafe0821b3cceb753b9ddb4076a79f26a88c0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Display creation time on container", "commit": "c55927aa9c7e575544901bbee41cb9a9d3c6437a"}
{"repo": ".", "date": "2025-03-13", "line": "style: Darkened the overall theme.", "commit": "0fad298fc078e2a3ea1afee71ee92b99b83427b0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added padding to links", "commit": "0f950218d6d783c4738966e15b2746c14043c82f"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Improved reply formatting with markdown and blockquote", "commit": "d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139"}
{"repo": ".", "date": "2025-03-14", "line": "feat: Increased update interval for times", "commit": "17c9731b9f8be07b247aeed29ba2ab1319e408f0"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Centralized input styling in shared.css", "commit": "5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Applied updated input styling across pages", "commit": "752f3df13a548d22646f87a87940dc64e15587f3"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor app modules and update script tags to use type=\"module\"", "commit": "a4d79b06c49ec9f605336bf181e98455c8acd460"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Convert files to modules", "commit": "a9663c8170dd2f925100eaa50e8c0019c5eee683"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Refactor socket and event handling for improved reliability and structure", "commit": "4a8a614adb5e15cad18414214d30ab83464eae14"}
{"repo": ".", "date": "2025-03-16", "line": "refactor: Improved code formatting and consistency", "commit": "c9c070c497bb9af3eb5bb9915f221ec00b56b832"}
{"repo": ".", "date": "2025-03-16", "line": "fix: Reconnected socket on error", "commit": "e62a8554090009c7914b95833066ad46251da01d"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Changed button colors to dark theme", "commit": "819cf8381c287e2ee88fb1d6fe789e30a1a33eff"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted upload button background color", "commit": "2ba28c193a826e8c1f9647f06bffb6084166c9f1"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Added Umami analytics tracking", "commit": "287e10d8aa8feb2590ad48d9412ace74a9432baf"}
{"repo": ".", "date": "2025-03-17", "line": "fix: Scroll to the end of the message container", "commit": "54416ee84f88064897a824ae2c3a9e0ef2c1ccaa"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Refactor header layout and logo display", "commit": "39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill and update templates", "commit": "7fa2817f773b47737236f4bb700023bcf8b483f1"}
{"repo": ".", "date": "2025-03-17", "line": "revert: Removed unnecessary formatting changes", "commit": "965dc930a900a5080e225bb492be2b799daed22f"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill", "commit": "825ece4e7868be28ba03c4fde5149695b0dd9dc5"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Added dump script for public channels", "commit": "3c6a0944d68ca16250ec9364d7f006b0e7eea6e8"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Improved dump functionality with JSON output and user information\n\nfix: Removed unnecessary print statements from cache and dump modules", "commit": "70db15bf27c7a8fd8bf112432f02754f01bbb3d7"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Refactor dump script to output to dump.txt and improve message formatting", "commit": "3960390ec45f427979ebd2b81c1a21666a47e71d"}
{"repo": ".", "date": "2025-03-20", "line": "refactor: Update session management for user registration and login", "commit": "5ba239caa8928a078362af0e6e2d1a4626bd508d"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and updated Dockerfile and compose.yml", "commit": "7dcabde2ed0ab699ea3033f7788381e85c352b97"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and basic shell environment.", "commit": "013d4adce57f4afec5176bbdb5e2225d529ec3b7"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal page with Ubuntu option and channel sidebar", "commit": "6e68408ddfd8be2376f7453deac8f63a6bfb93e4"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Add channel list and user context to template rendering", "commit": "604e27ce10dee59b1b3f6ebd359ee356b085df2a"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c72b015073347e86f2edf1e67544b1ae31e929b0"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Disable r executable and add vim and htop to apt install", "commit": "78c631e6c79b4b69b0e202a301b710c4150e6dbe"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c8461342fd8d260da69c84f27f9e9d13b3430942"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Introduce ThreadPoolExecutor for asynchronous task handling", "commit": "0bc24e8d2ef4452efc7be5286288a2531908ea55"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Improve terminal display and handling", "commit": "c2d9af807a95900c4fecd7a1929c8d92393a955d"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Install git during setup", "commit": "c5c160baae67d7e5932963f8501ed7d56dc35c21"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Updated drive functionality with new views and services\n\nThis commit introduces a new drive feature with the following changes:\n\n-   Added DriveModel and DriveItemModel to represent drive data.\n-   Implemented DriveService and DriveItemService for managing drives and items.\n-   Created DriveView for displaying drive contents as JSON.\n-   Updated Application class to include drive-related setup and routes.\n-   Modified Makefile to use python3.12\n-   Updated src/snek/app.py to include drive view.", "commit": "7b32a7eba4d5944142a3b40616d37b0862087371"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Add URL to drive items", "commit": "dec2281ac88d151afda016fc01e833dc2f0aa89e"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Correctly fetch file extension from item", "commit": "1de2c55966c0ddf0fb663b935427d2c005f0fde9"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Improved terminal session handling and websocket integration", "commit": "529606955a545bc25cf5899f2c79d3660bcefd54"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Increased websocket thread pool size", "commit": "af4a70e8949bfde704a0499177050fdeab5300d9"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Display user color in sidebar and update new message count", "commit": "5390b8bdc3dc04645258ed758f3894de00008e80"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Prevent errors when user data is missing during notification updates", "commit": "877ef7970d5b7bb5f8fd0af35adad5d8d071b14d"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Remove debugging prints", "commit": "87c189b3fe897cc15ee6ac311e987bf9fe7811b9"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Cleaned up console output by removing unnecessary print statements", "commit": "71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction for notification creation", "commit": "bd5bb5ae65d6cb870d090382b106b705833d4cf1"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Persist RPC transaction changes to the database", "commit": "13ce09a5c50ddba8956dbeb838f9dd1bcafe184a"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task runner and task queue for asynchronous operations", "commit": "145373399dada2f4cee54af0c99e62d4a27a0f99"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Improve task management with asyncio.Queue and error handling", "commit": "e6f702a6b405f1dae0ce719177c6f7bf5b636ad8"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Remove unnecessary database transaction block in RPCView", "commit": "9d5815ed1028354ac61f2d506530147a02c476e6"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction management for task execution", "commit": "8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task execution time measurement", "commit": "6ad3844f037735e268b42247fcc6e8605cc13f07"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Schedule notification creation as a background task", "commit": "73e8779bdc42ec5f5618fad3d563544d1fad2b69"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Ensure notification creation after socket broadcast", "commit": "6f043d21390d13d37772c8ae145d7a66b3919529"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Await task creation in chat service", "commit": "9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec"}
{"repo": ".", "date": "2025-03-28", "line": "feat: Initialized Ubuntu Dockerfile and terminal environment setup", "commit": "5fbcadad8bad7b8dd7ac3279938ff50db6b5d380"}
{"repo": ".", "date": "2025-03-29", "line": "fix: Adjusted avatar size in message template", "commit": "fe1b3d6d191176111538f1ba06018e14c44ca8f9"}
{"repo": ".", "date": "2025-03-29", "line": "```\nfeat: Added webdav support\n\nThis commit introduces webdav functionality to the application.\n- Added webdav dependencies to pyproject.toml\n- Implemented WebdavApplication class in src/snek/webdav.py\n- Integrated WebdavApplication as a subapp in src/snek/app.py\n- Added necessary imports and configurations for webdav integration.\n```", "commit": "29139d5d0c18ad2b0ebd32db9a0b629c45c0a651"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Initial implementation of WebDAV functionality with basic operations", "commit": "9a923f6bddd73df27af80ef6c8e2313816a07a48"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented basic WebDAV authentication", "commit": "886d21999c716ee306318796f3f159ba085f9618"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Use home directory instead of drive for user folders", "commit": "6dca3a96e1cd23cc26d69aeee31ae45e14977bbd"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implement recursive node creation for propfind", "commit": "3926b2d837bef181546d826b41821cf90fc755a8"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Updated home folder path to drive and removed lock implementation", "commit": "d5917b94540aee206935354f438a6a7f893278ec"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Remove unused LOCK and UNLOCK routes", "commit": "8058e4a4b0aee254edc76fa28b2c4336eb393c4b"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Handle potential errors when creating user home directories", "commit": "2a47c0ba5e7344d44c3acd15d8f3efeb11722677"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added settings page with profile, notifications, and privacy sections", "commit": "32e0c959e8589f574d1c46b96d35f49c98721566"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Improve responsiveness for smaller screens", "commit": "87b48af551d2ed023f77ca57d033ed0079d303f3"}
{"repo": ".", "date": "2025-04-01", "line": "refactor: Reduced message list height", "commit": "18b1ec20b67522cf816b29f3cde64a935ec5b330"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Removed chat window element", "commit": "7c52c2d9d5f10623ccf479918ea5baed247a07b5"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added footer styling for improved layout", "commit": "fbd4fa4e668628c11d8b592718cfcdcca71a3c0f"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Style adjustments for mobile view", "commit": "d24627b35fd2201e6baad781a11fbae0c379f366"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted width for mobile responsiveness", "commit": "e3c997302b07228c0791d6307b429109b6eb3d53"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted form width for mobile responsiveness", "commit": "27c0abea3147e5e00a5196fa9b351141a9d17ae2"}
{"repo": ".", "date": "2025-04-02", "line": "fix: Handle missing last message gracefully", "commit": "b365afc910522a484ad257af6df7b607712c71f2"}
{"repo": ".", "date": "2025-04-02", "line": "feat: Add unique request ID and improve UI responsiveness", "commit": "99b2beeab0d55242537b7ec810bfdd69feb47103"}
{"repo": ".", "date": "2025-04-02", "line": "style: Adjusted layout and overflow properties for improved responsiveness.", "commit": "81479e7058feda9954fd74810d1294fb92e7a1c4"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Refactor settings view and sidebar", "commit": "d10768403d221fbd1d50a520c083b8d1be1b3a19"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Added profile settings page with nickname and description editing", "commit": "69482207461eec9c3c64ec297231989aa248dd9a"}
{"repo": ".", "date": "2025-04-06", "line": "fix: Update icons in manifest for stability on Firefox Android", "commit": "c2b8061ac292f18949d81c660c5a314cb42bcc6e"}
{"repo": ".", "date": "2025-04-07", "line": "fix: Resolved web manifest icon instability on Firefox Android", "commit": "75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added IPython dependency and improved asyncio handling", "commit": "d71d5da6bcf22d2daf5ec59832f15fe02472b95c"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Implement user property service for storing and retrieving user-specific data", "commit": "d2e2bb811707b02f05cbf22d10ef1916b021c90d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added debug middleware and improved routing in WebdavApplication", "commit": "d23ed3711a464f1d796ed35e58dbcaf1db6b7d84"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Add debug middleware and improve WebDAV functionality\n\nThis commit introduces a debug middleware for request logging and enhances WebDAV functionality with several improvements:\n\n-   Added debug middleware to log request details.\n-   Implemented caching for file size and disk free space using `time_cache_async`.\n-   Fixed potential errors in user authentication and home folder retrieval.\n-   Improved error handling and logging.\n-   Refactored code for better readability and maintainability.\n-   Added support for asynchronous file size retrieval.\n", "commit": "13f1d2f390afdfc912d24bb63930c9ca47e05f94"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Support short and standard YouTube links in template", "commit": "b31c286a8b8442d48f9b0713a8cce41432c168d1"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "b0a97ad267b971f8ba298bb5a0e696810c08b026"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding with improved parsing", "commit": "c6575d8e525dfa2f574e1965cd3df4379cde7acd"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube video embedding logic", "commit": "6138cad7827c48a86b20d4015dce818dca348f04"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Style YouTube embed to center horizontally", "commit": "087f9c10b44fca9f29de862562b405ef5586f151"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add support for YouTube video embedding", "commit": "e6bd7aa15211ae0bd3be65be3a659526b1131eee"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added video embedding functionality with improved layout", "commit": "94e94cf7ca4bdcdd581dfe074728e93412c2a621"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "6673f7b615508f0c344fe0efbebe362f5236bd84"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding from text", "commit": "2582df360ab0667a3d29c46b92ad4abeb397d363"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "656ea5f90ee56a16b0f0047cace848572dc479c7"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube embedding logic", "commit": "c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Refactor asyncio and database preparation in Application class\n\nfix: Corrected indentation and added missing executor assignment in prepare_asyncio method.\n\nfeat: Improved form element structure and added missing attributes in profile form.\n\nfix: Corrected user property JSON serialization and retrieval.\n\nfeat: Added UserPropertyService import to service initialization.\n\nrefactor: Adjusted YouTube embed template for better compatibility.\n\nfix: Corrected user data retrieval and handling in SettingsProfileView.\n\nrefactor: Improved file size and disk space retrieval methods.\n\nfix: Corrected lock token parsing in WebdavApplication.", "commit": "44dd77cec5639575cb86973eceb8d174d570370c"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Minor formatting adjustments across modules", "commit": "743593affe276ae8ffd3751c80fe88eb4c99ac7f"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Removed comments and added channel display", "commit": "0e6fbd523cd4f4279a4f230567504b30c9b3116d"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Improved channel broadcasting and added user UID retrieval\n\nThis commit introduces several enhancements:\n\n-   Added a `get_user_uids` method to `ChannelMemberService` to efficiently retrieve user UIDs for a given channel.\n-   Refactored the `broadcast` method in `SocketService` to utilize the new `get_user_uids` method for improved performance and error handling.\n-   Added error logging in `RPCView` when deleting sockets.\n", "commit": "3594ac1f5984953487e0c3423c9672b01e416c28"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Add stats view and caching statistics", "commit": "bc65752ea252cdcd929ba0bd956455317958337a"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added stats view endpoint", "commit": "a1840cd034e7a4c792e2bcc69ff06595b1e2add3"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Moved 'r' executable and updated .bashrc for automatic updates", "commit": "22668f8a72994446ffaa109e5ae742bd61bd3bf2"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added initial .rcontext.txt file with facts and work procedure", "commit": "ec9af49f2903682cea978db15422fba4624c488d"}
{"repo": ".", "date": "2025-04-13", "line": "docs: Added a reminder to be rude but functional.", "commit": "9b49e659e575e99de717a5c64e1ba1c3c4039cb1"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve process handling and error management in TerminalSession", "commit": "823892a3021e674fea933b717565518dc1696031"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Return empty list when search query is empty", "commit": "4a770848a6dbc558c029b083a881becf7adef8d7"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require both logged_in and uid for login_required views", "commit": "e4b0625799d9efd89e7e9518278588158b296c6c"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Require login for search user view", "commit": "8ae9aac045e41fc84ebab102335a2613b3e22c08"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Allow profile description editing and nickname updates\n\nThis commit introduces profile editing functionality, including a textarea for descriptions and an input field for nicknames. It also fixes a bug in the user property service and updates the profile view to handle form submissions correctly.\n", "commit": "bee7d828cd67581c33946630cd22fe8edd674d15"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile view and link avatars to user profiles", "commit": "3b05acffd296169eed305a55dba79d632d5f78f5"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Implement user profile view and template", "commit": "a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Refactor user property setting logic using upsert", "commit": "9fb6e64655dff132be43e2fc867827d17ad94201"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Improve user profile rendering and link avatar", "commit": "0fa04883850534fbb97755e06ebec1538dccfdc7"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Use user uid instead of request user uid in user_property.set", "commit": "d4f5a4640929b1f16ccddc741f977cf7e901e7de"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added navigation links to user page", "commit": "3cfb79c8f560430639fceb4a278fc81dfbad2299"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Removed fixed positioning from header on smaller screens", "commit": "c36ce17da5fcccfdaaf7ddf7579b0399519d078f"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added chat area class to user profile section", "commit": "4cc70640e4f7c4d65e5a0c3a503aae1f891164d5"}
{"repo": ".", "date": "2025-04-17", "line": "feat: Refactor layout and styling for user profile page", "commit": "1cd0b54656a969bcb87cbdc07687866ca43e650b"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added command-line arguments and executable script", "commit": "46a8b612b49f1094c0a8520d97d4b5642f2a57e9"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Configure setuptools to find packages in src directory", "commit": "061da150f9779c6130fac0c957d4facdd59aa33a"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Handle missing pty on Windows", "commit": "6312dfae47b09753675b038798cf38f00311e772"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added UUID generation and updated hashing functions", "commit": "c709ee11c99f54b58844165b6eb9993240ab0005"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added paste and file drop support for messages", "commit": "f7fda2d2c951a07bccc927a333b7feaa527556c2"}
{"repo": ".", "date": "2025-05-06", "line": "fix: Use user home folder for uploads", "commit": "529ebd23fc0b50e2606ecddc5e1199774dd18384"}
{"repo": ".", "date": "2025-05-06", "line": "Merge feat/copy-paste-drag-drop", "commit": "0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Implemented file paste and drag-and-drop support", "commit": "b0666a00900e1b25633433b80da1ef3dd5f2ee71"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Dispatch upload event and focus input after upload", "commit": "707788583a2387c1729950e08243d3f8f7049d7c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added focus functionality with Escape and G keybindings", "commit": "d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2"}
{"repo": ".", "date": "2025-05-08", "line": "fix: Prevent double focus loss during upload escape key press", "commit": "fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "e153811ff34ef63892cc6aac1d5afd92cb510d14"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "0a3e15137761d333211d8b52d178f5e150a579f1"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus upload view when shift+G is pressed", "commit": "f6706c165e2a8ba392bde1e81d8006381fed96d3"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after layout update during shift+G", "commit": "8799662159656867494b2774073b3bfb1bbe5178"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after upload", "commit": "49ec99ef016dc754e36442d774da1d3a712bf2a7"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated Dockerfiles and terminal configuration\n\nThis commit updates the Dockerfiles and terminal configuration with several improvements:\n\n-   Added `xterm`, `valgrind`, `ack`, `irssi`, and `lynx` to the Ubuntu Dockerfile.\n-   Added Rust toolchain installation to the Ubuntu Dockerfile.\n-   Modified the `r` executable placement in the Ubuntu Dockerfile.\n-   Updated the terminal.html template to include fit addon and clear the screen on connection.\n-   Added `$HOME/bin` to the PATH in the .bashrc file.\n-   Removed the `.rcontext.txt` file.\n-   Added `resize > /dev/null` to the .bashrc file.", "commit": "3c1d5d601fa1a9f30b2aa4fd36086102108dde94"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added ubuntu docker build target and simplified run command", "commit": "d0dd342e27cf4160f96faf87deff81f728e41e47"}
{"repo": ".", "date": "2025-05-08", "line": "chore: Updated Makefile and .bashrc", "commit": "31062fddbfbbf25f206060b38773a7e2c008723c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Install tmux", "commit": "3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement user template loading based on admin status", "commit": "02a0253c1d9c73d2918fecb8f52c4c7739c867f5"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Prevent appending None to template paths", "commit": "165dda32100e52c347c7f6bb71062244b2a50ba1"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "ac570d036c26b6c7ff5abac169fdf622c10827ad"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "b867b6ba78574a332bf951eb6c00a6a88ded325d"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use request.session instead of self.request.session", "commit": "e359a8ebe294e0f55cf4164926011e893468e4bc"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "5b28044d9e604b2404bf4b7277a240f7fc56032c"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with `add_static`", "commit": "c56bf4fb49c986e9b653f635a81937a3ef433a5e"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository management functionality\n\nThis commit introduces repository management features, including creation, updating, and deletion. It also includes UI updates to the settings sidebar and templates.\n", "commit": "ee40c905d4448f0b39d28c4d51343b5fe111d038"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added basic Git repository management functionality", "commit": "a5aac9a33701e3d4852fba13520771e6de82aac0"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Corrected repository deletion URL and added repository deletion functionality", "commit": "e06776d81d0fa40e4d9d5f57a6259df8db271372"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Add uvloop dependency and fix repo path resolution", "commit": "adb59eff68e5c855fbce6f930db1ea13f59683f6"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "3ae30f1f7645203a8e8c15bd298d802fffbd2334"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Increase client max size for uploads", "commit": "e5d155e1249f9df7c504a95c98171f7e4fe5d5a4"}
{"repo": ".", "date": "2025-05-09", "line": "refactor: Migrate from argparse to click and improve application startup", "commit": "7e8ae1632d19238954ca96657da1d3950ebd413c"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "95ad49df432195cb127f9fe695eac14678422b37"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "17c6124a57a394c63427a0038e598fdb40560f15"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Initial file manager UI and repository view scaffolding", "commit": "4c34d7eda58530eddb2c8b3479627180d6eeb248"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "1616e4edb97284f705400c0598306202a083f60f"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added DBService and RPCView db methods", "commit": "dd108c20044540c3801ac461c612392bed76ff89"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added Drive API and HTML views for file management", "commit": "f0591d493955d9c126e7dee6d1a06917c48bbbd2"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Implemented typing indicator and glow effect for active users", "commit": "3412aa0bf0c0bb138c234f88fad55cb69267df79"}
{"repo": ".", "date": "2025-05-10", "line": "'choices'", "commit": "9133b7c3ce6457fa6c218b540828c752b4ba5c72"}
{"repo": ".", "date": "2025-05-10", "line": "fix: Remove debug print statement in SocketService", "commit": "4d7566de9bb3f2c54954fe72d0332caecd133ffa"}
{"repo": ".", "date": "2025-05-10", "line": "refactor: Remove hardcoded repo and user data", "commit": "2c9004418555dfc2a4c826e5c30aa0d59f332df7"}
{"repo": ".", "date": "2025-05-11", "line": "'choices'", "commit": "01846bf23f7883007b99a2e100240bf3b35b30f2"}
{"repo": ".", "date": "2025-05-11", "line": "'choices'", "commit": "c48b84bf3ab7cff5dee5670e23db3d771e14fc46"}
{"repo": ".", "date": "2025-05-12", "line": "'choices'", "commit": "f156a153de1b2f89b99cf0490eb18bf27a611fe1"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added image conversion and resizing support for channel attachments", "commit": "ac2f68f93fd66c0d6ab3682525b2d9c94febff4d"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "a4bea9449526fc8f6b01c02d777db5c30186b830"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "ba3152f553afcfae318811a413cdea6f5be9f413"}
{"repo": ".", "date": "2025-05-13", "line": "refactor: Moved WebSocketClient to system/websocket.py", "commit": "adad5ed4fe37038442d247d9246795a82d31093c"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Remove timing output from task execution", "commit": "2e324ff11815d3c67fffa8e8d5f3e3554f154b57"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Implement image click to view full size", "commit": "d09055986e9a5d971f58075a5e939a268deb26be"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Add width parameter to image URLs", "commit": "8cd2f16c5c46318cb035b882197b516ae5532452"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added width attribute to image source", "commit": "015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "12d287042415554c581e7d4fcfc81bd3d733fa02"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "964a747f42ade75dcfced5395f46727f0508172c"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "319c1b1b5264933a7ea1d7af6541be2a410a3328"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "a21e3590ef4ddad292fb914cb3454d07eb622413"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "b55d74fb124b90ee24d158bc94c401b0ff19edb9"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "3858dcbd62e4032a02e9d25dffd000ade4dc7bbe"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add height parameter to image rendering", "commit": "0ea0cd96dbe536b09cb3549de47766a756f04008"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9"}
{"repo": ".", "date": "2025-05-13", "line": "'choices'", "commit": "c45b61681dc2fc2a239e1bc2672da44f7738b0c6"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Introduce online user list and typing indicator", "commit": "db6d6c0106267f56822ae378a4c88385d025051a"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Updated message text updating and added message age check.", "commit": "25d109beedf030523ac5d357dbbb1f9efb919edb"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Implemented dialogs for online users and help, removed online.html", "commit": "dd80f3732b7f500acdd92f6e44f42f9ade0f205b"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added /live command to help dialog", "commit": "79c39828f0a3282f53e3322e19b211b5559466a1"}
{"repo": ".", "date": "2025-05-16", "line": "'choices'", "commit": "c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Update socket service and attachment view\n\n- Added last_ping to user data on socket connection.\n- Fixed bug in attachment view where format was not correctly passed to image.save.\n- Improved attachment view to handle multiple attachments.", "commit": "93462d4c4b93c4f7eb81702801df9159cbb64e8e"}
{"repo": ".", "date": "2025-05-16", "line": "fix: Increased last ping cooldown to 180 seconds", "commit": "c387225a6e8aa826b944ff3c53c4045db25db758"}
{"repo": ".", "date": "2025-05-16", "line": "'choices'", "commit": "00557ec9eaab7256d5c129fc8c00c12650ea3fd3"}
{"repo": ".", "date": "2025-05-17", "line": "'choices'", "commit": "c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor chat input component with improved auto-completion and live typing functionality", "commit": "48c3daf3983e3b6e04a0c5888febceb69db9d661"}
{"repo": ".", "date": "2025-05-17", "line": "'choices'", "commit": "ffb22165da408d653dec3baeed2a5aca972eaea6"}
{"repo": ".", "date": "2025-05-17", "line": "'choices'", "commit": "1bed47fbf59f3fb9bfeeae955272f6b2442d92b3"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Re-enable webp fallback with png fallback", "commit": "53811ca9b2259cd24dcada84816c3e622b801835"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Added star animation to sandbox page", "commit": "e79abf4a26454cddf766cd1ba138554817c820cb"}
{"repo": ".", "date": "2025-05-18", "line": "Merge youtube embed fix", "commit": "59a2668c8c63c0292623cf410a3806666940f49b"}
{"repo": ".", "date": "2025-05-18", "line": "fix: Improved GIF resizing quality", "commit": "0f337e569fba59018d41628e9e471cf0c088113e"}
{"repo": ".", "date": "2025-05-18", "line": "400 Client Error: Bad Request for url: https://openrouter.ai/api/v1/chat/completions", "commit": "e09652413f3affac0048cfa53f46fac87aa058a7"}
{"repo": ".", "date": "2025-05-18", "line": "feat: Updated theme and added star animation to index.html", "commit": "e1727caa5fbb379da6fdf7f96184834f2644baa3"}
{"repo": ".", "date": "2025-05-18", "line": "feat: Added container management functionality", "commit": "527b010b244f664b44078d65f34f8b1795d2ce12"}
{"repo": ".", "date": "2025-05-19", "line": "feat: Added container management views and API endpoints", "commit": "db5431d77d48f7561ba6f874e74ebd7bda0abd88"}
{"repo": ".", "date": "2025-05-19", "line": "fix: Increased glow intensity for CSS variables", "commit": "8a85cd7990fcb08071ccdf25f57a8957efd9c57d"}
{"repo": ".", "date": "2025-05-19", "line": "feat: Added starfield animation to sandbox page", "commit": "00fce6bd68a0204020bc5674fdde4237e16cf256"}
{"repo": ".", "date": "2025-05-19", "line": "fix: Increased glow intensity for CSS variables", "commit": "2e837f96c548256bce505ed82fa6e9a3f75449b3"}
{"repo": ".", "date": "2025-05-19", "line": "fix: Reduce star glow intensity and add updateStarColorDelayed to app", "commit": "59a815f85a6783a3d1c46476d671c9817c1b6575"}
{"repo": ".", "date": "2025-05-20", "line": "Merge youtube embed fix", "commit": "d261f54327c49f10e97a838ed78f3a68c7b1ad74"}
{"repo": ".", "date": "2025-05-20", "line": "'choices'", "commit": "87b6b3362db84884fe967702f8c843ec477c589a"}
{"repo": ".", "date": "2025-05-20", "line": "fix: Merged youtube embed bugfix", "commit": "c322d6147a2ee7f59c59a565788b230a18f971ea"}
{"repo": ".", "date": "2025-05-21", "line": "feat: Improve message hover interactions with enhanced avatar and time visibility", "commit": "9f577875f297a3d7619c618382a3c6181d511e72"}
{"repo": ".", "date": "2025-05-21", "line": "feat: Improved hover interactions for time and avatar display", "commit": "b2a4887e2394e8a2e40b5f25c19788cc3155f01b"}
{"repo": ".", "date": "2025-05-22", "line": "fix: Correct timestamp parsing for start time", "commit": "a11c336cf5d169eab170885847dc2e7859e9fc8d"}
{"repo": ".", "date": "2025-05-23", "line": "feat: Improve chat input and message display with various enhancements.", "commit": "f0545cbf02a963557f44af23fb104598ec2e3ff8"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "a0fb214332f0481eb6674883db041be20b68b585"}
{"repo": ".", "date": "2025-05-23", "line": "fix: Ensure message list scrolls to bottom", "commit": "431748c489e4fe9e80a8a2846528a824bbc372dd"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "a55d15b635363229c86a9791dddd5cb12436a1d0"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "3bf09f908391ae45e99ddb506a7ce174b5b6c4f5"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initial project setup with basic files and structure", "commit": "66f89429366042c77599f3a9b8c1a7aecf976a4f"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "539fb262b23770b270bb3144e991c85c27cdb624"}
{"repo": ".", "date": "2025-01-17", "line": "feat: Initialized project with basic description and setup instructions", "commit": "46a27405aeb8ec426fd1c686a2c090f9fe9c0e62"}
{"repo": ".", "date": "2025-01-18", "line": "feat: Added basic login and register pages with styling.", "commit": "a7446d131413da9f013a56d3541192d8ab1e22b0"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "1c873b7d023b2915b88c6d80af12ee23cbba8f8c"}
{"repo": ".", "date": "2025-01-18", "line": "docs: Added restart policy to snek service", "commit": "2e3b85d7f739160783e7c5552f1306298047704a"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "30b78715831e6e377f58cb15bddfe9e376d76ab1"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor project structure and introduce generic form components.", "commit": "ba83922660dade77dcb96e8ba9c73cfcba8c2b81"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "9b9d3568495454ec777406101fbfaccef8e3f27c"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Complete system with basic functionality and initial views", "commit": "d20079f3ed8f261bcda0f5379f4c9e23ee941527"}
{"repo": ".", "date": "2025-05-23", "line": "Merge bugfix/youtube-timestamp into main\n", "commit": "e33e4196ab6301f16451c2b37cbaebaae6e47be5"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Use \"/back\" URL for back button navigation", "commit": "4b48485bcc9f73662a1eb4ab3142bb0f4d5bef7a"}
{"repo": ".", "date": "2025-05-23", "line": "'choices'", "commit": "43982c16fa6bb080b9ae61848a54eb44da403bbb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implement size attribute for fancy-button", "commit": "0271e3f9719a4155fc5be37c36feca865932b0c1"}
{"repo": ".", "date": "2025-01-24", "line": "fix: Updated login and register button URLs", "commit": "bda93e354f4691483dbbef29949672ab0989b7e0"}
{"repo": ".", "date": "2025-05-23", "line": "feat: Implemented mention matching and replacement in chat input", "commit": "cd8ea9afd69e1e12b3396cf46b76f6219191c1fa"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Style adjustments and layout improvements for responsive design", "commit": "757b67b78c2f396df7ac7b5706f98833aedfb85b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor styling and layout for improved aesthetics and responsiveness", "commit": "6ba6121988dae019d4c4c0a3b8592b443f094065"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor routing and form endpoints to use /login.json and /register.json", "commit": "c1eeacc0b415ef770418bb053d18ac0ffa4f64c2"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented caching decorator for asynchronous functions", "commit": "21ab5628b072320ae0851819116e57539b2397d9"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Migrated to Gunicorn and added documentation pages", "commit": "8486c22c325ba358bd48766c518f7c7bd30059eb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added API documentation with code examples and HTML template", "commit": "aecd9f844ef0a277a55aa536db3336362e8db353"}
{"repo": ".", "date": "2025-01-24", "line": "docs: Added styling for dialog element", "commit": "be9489f939b3518f9c3a73b9e54ba0f9d34ae24c"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Implemented user registration with username availability check and email field.", "commit": "2ba55f692dfc1b60ac55d514b182fb8834cb99bb"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Add documentation subapp and markdown extension", "commit": "18b76ebd5e2f11451db04800d426a16b1ef1dd14"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Refactor form validation and rendering for improved consistency", "commit": "9b93403a93ac0b03a57fb5dc10db5c35349c4d6f"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Added documentation and initial form API examples", "commit": "b56371994f5d3f5c1aa5d63c28efd18856ea8e9b"}
{"repo": ".", "date": "2025-01-24", "line": "feat: Improve document handling and error responses", "commit": "dae877113c76b6f0eded7b2d63ef921123a2b559"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added session support and login functionality", "commit": "5c69e14d7cfae8da4efab776165cc8e466edcc41"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Implemented status endpoint with user information", "commit": "352d2deb12a471bc90425961849fb2e92da3ab16"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor session management and improve status endpoint", "commit": "12ca8e4296ca9693276422e524d7061685556ba0"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Added logout functionality and improved login form validation", "commit": "bb6bcf41d1bb2132684b6251853f7d34e202a9f7"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor service and mapper setup, introduce Cache and Object\n\nThis commit refactors the service and mapper setup to use a SimpleNamespace for better organization and introduces a Cache class for caching data. It also introduces an Object class for handling data structures.\n\nThe changes include:\n\n-   Using SimpleNamespace for services and mappers\n-   Implementing a Cache class with caching functionality\n-   Introducing an Object class for data structures\n-   Updating the application setup to use the new classes\n-   Adding new models and mappers for channels, channel members, and channel messages\n-   Refactoring the user service to use the new cache and object classes\n-   Updating the login and register views to use the new service and form classes", "commit": "b4f9ff2c628ffd5aafdfbc4a403b2b71fa0110c8"}
{"repo": ".", "date": "2025-01-25", "line": "feat: Refactor model and service imports for clarity and consistency", "commit": "f25feeeca3502eee94554e7152ca7ca946115053"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Added RPC view and WebSocket support for real-time communication.", "commit": "488afdcc747df9593273f652b17b5fe8db07b1df"}
{"repo": ".", "date": "2025-01-26", "line": "fix: Minor formatting and whitespace adjustments", "commit": "4c601e8333b3a462c63ab6e02b73b9f5306b4a58"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Implemented basic chat functionality with message sending and display", "commit": "4ae846cf8b4f1158ac47ce2825d37e03e9b6677f"}
{"repo": ".", "date": "2025-01-26", "line": "feat: Use dynamic websocket URL based on environment", "commit": "fb7cb35921b73fd22a4ef045fe23b8dab87a7af4"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improve WebSocket connection handling and UI\n\nThis commit introduces several improvements:\n\n-   Added error handling to WebSocket send operations in `SocketService`.\n-   Updated the WebSocket URL in `app.js` to use port 8081 for local development.\n-   Implemented a `query` method in `BaseMapper` and `BaseService` for database queries.\n-   Added a `chat-window` component and updated the HTML structure for a more modern chat interface.\n-   Implemented `get_messages` method in `RPCView` to fetch messages from the database.\n", "commit": "36c69eb8bb35068faebd396af1375fe5927eec44"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Initial chat window component with channel loading", "commit": "87895a72d3ddb5f3ca98e4409f251e663e6dd688"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Added snek.d* to .gitignore and configured database path", "commit": "aec9ffd1a1a49acad8940b793be6ff3abcae07a3"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Add notification service and related mappings and services", "commit": "4f71f745744b1a413a729875bc42366ea3ab665d"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Implement chat input component with basic functionality", "commit": "2a3e225e1dbb40374e841af8977ff19cd4711f0c"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Use user uid from database after login", "commit": "188a1e61783a7d08cb7ece1fbcd332aa1f19672a"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Disable debug logging and remove unnecessary console statements", "commit": "26210f8c09c81f4ff4f7ed796d5d8bcd6d8b639e"}
{"repo": ".", "date": "2025-01-27", "line": "fix: Removed console logs and initial message", "commit": "095e30a92f6d12edf16ca87d66b335088b853490"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove unnecessary benchmark script", "commit": "374db23669e203c98e5335b9a7abe9aff2110537"}
{"repo": ".", "date": "2025-01-27", "line": "feat: Improved cache logging and socket cleanup", "commit": "f3d12a257e7a43e3292654d7f67f05d823f16283"}
{"repo": ".", "date": "2025-01-27", "line": "chore: Remove generated pycache file", "commit": "8e825a90c6e575f114b380312bb9c5726577b8b7"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit table results to 30", "commit": "01d8093e7210910016ea5d6d8bbc5d8f2514c14d"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Limit messages to 30 in RPCView", "commit": "d93d48ef7e023c62bfa9b64ede20cd9f86c3242e"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Improved message handling and added scheduling for event dispatch", "commit": "da72a15068fe14eeb2b50b4cd3342fb4b70b0c79"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule functionality and minor UI adjustments", "commit": "99d335ac244c2258d82821344fa517857a782f4a"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added schedule benchmarking with custom messages", "commit": "4f1a48c197fcad25d80873bac55cf66f7ff99382"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound on new message", "commit": "5aee606d5d65e71afa8366d24ed4632f662a9126"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added notification sound and improved chat input functionality", "commit": "14c59ba5c0abc7d1331e022cc99222223ea21526"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Improved connection handling and PWA support", "commit": "b2ca373081bdd7514b0f849dc1033edfd3f76424"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Added favicon and manifest for PWA support", "commit": "7d05bd9da45489c02a9b057eef86d45e2ca90049"}
{"repo": ".", "date": "2025-01-28", "line": "docs: Added display and start_url to manifest", "commit": "4da635502bca60efd0cc59aa4df236d7b99c2ec2"}
{"repo": ".", "date": "2025-01-28", "line": "refactor: Reduced padding in chat messages", "commit": "d69c75c6197e857ad61e4dbc872b5ab5872c4837"}
{"repo": ".", "date": "2025-01-28", "line": "feat: Add data attributes to message elements", "commit": "9e94210bc3f3b1b614a198591c52f404d84a8be2"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added user color and updated message display", "commit": "84e5bac1b93d5d1c124d303e6b08a29baaf4977c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add utility service for generating random hex colors", "commit": "284d38096c7c5b1201f261ec7a5a28ed457952b5"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added avatar color and text color", "commit": "93b2f6cc41f08e21241642976b90e3dd98dc37ec"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add linkify functionality to message text", "commit": "9f652ece1bf0498f9032f94b77becc96b6eff009"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Increased result limit to 60", "commit": "16afbb4e15f370babeedfc2aa917daa0292da5a6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide avatar and author until switch-user message", "commit": "41927b7ef439424326cc58e3939f476e04b8eabb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Add padding and switch-user class for message differentiation", "commit": "75ec590be5fc3f446c97549d90c135966142ac25"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Show message time on last message and switch user messages", "commit": "0e821f8b588def99f950fecb9369456cff086e0b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Ensure HTTPS for external URLs", "commit": "931aae5134cad80bf7f5ba87fe215a03761f081b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added html-frame.js script tag", "commit": "c558dc2d79b90e7424cf4311747f077332b0a193"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected typo in script source path", "commit": "5f3dac8bc6b702735383688de44ad7609264742a"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Ensure URL is HTTPS and handle relative URLs", "commit": "4442f75ec50d3d27cfae1702459d5f8f34ba415b"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added padding and URL handling for HTMLFrame", "commit": "030942db0984ac0f3a4072581d58d81fad03ef91"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install prompt and button for PWA installation", "commit": "438fad301447e3265ff7484606f8222b271e4d9d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added install button to navigation", "commit": "3e4b6b00620f8cf2c8b8c63918e6c93d2987174d"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Hide install button on initial load", "commit": "1f5dc57d6f24b17fa66ab5692038e005cc444378"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render messages with HTML for rich formatting", "commit": "03c72e85f72207a7b2480f881f7b0cb7055c5feb"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added basic message template with markdown support", "commit": "561a915e30274d8b191678135912313ebccde70f"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project to snekbak", "commit": "d7c003c4096f8cfed8f4edd517f41d45f4f8b501"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Refactor project from snekbak to snek", "commit": "f9fed90e861d8bc5ae5bcd89cb07bd67a1e66a98"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added documentation and initial form API support", "commit": "b562d171674c2f75592ff3a0dd25b51d2a2457db"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added cache to .gitignore", "commit": "82de0f304469e6214169a2bdcf9c65673baa9e76"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added docks to message template", "commit": "80f1bbc05e612c45fb2ccbb629a6aa3b468c627e"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added snek image", "commit": "9e89e27c6688b0e05e4a10a0538d599f82278e64"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added notification audio and scheduling functionality", "commit": "af399e3b72c772ed97e943e7d71dc6384ab8ccc0"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added Docker configuration for deployment", "commit": "3be25285f4f0afaaf991ee7cc0a8f71854e8de4c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Enable markdown rendering for messages", "commit": "75cb7605cd5e8e91cab2ffbc9000eb5987e40136"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Render markdown messages with raw HTML support", "commit": "4fbfe90a1309ec7bf7bf1d19465a3fc441aaddc5"}
{"repo": ".", "date": "2025-01-29", "line": "chore: Remove generated .pyc file", "commit": "f69586ccf7975be0bdd24659d6acec068f5183d6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added requests dependency and template extensions for linkification and python execution.", "commit": "bca39a612cad5f340864a4dc62d94cda962985f9"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Style message images and add time ago functionality", "commit": "5b88350ff27b526c5e4ee938d0665d3a4e1b5b5c"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Improve time display and linkify URLs in messages", "commit": "20d8d27f03e87bf06515d0664a00e669b92df49f"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Prevent linkify_https from processing non-https text", "commit": "3d6e1d2e943baabaf0b0875284bf18132bc3967a"}
{"repo": ".", "date": "2025-01-29", "line": "refactor: Removed unused padding rule in base.css", "commit": "5c4c5793899776e5d369f3949b4a8142a68ba7ee"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Display install button inline-block", "commit": "a8e3ad1af9f683ad25730ff48180c5306f72e1f6"}
{"repo": ".", "date": "2025-01-29", "line": "feat: Added username to chat messages", "commit": "99cea506de5ea0c5b373869b7c28d965b8af55e6"}
{"repo": ".", "date": "2025-01-29", "line": "fix: Corrected calculation in timeAgo function", "commit": "03e90039695abc0fdc9276980bd8728bd8951f05"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added SSH service and basic gallery styling", "commit": "b06a10f6eca08f312c4f53fac36a4a8dcd9d91b5"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Initialized SFTP server with basic authentication and file serving.", "commit": "15de277a5be330fe6962e5271c537e3b5ef40de4"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Enable unbuffered python output", "commit": "8eff6dd6cb7a8ccf866f8f98d22d3aea59c572f6"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for response messages", "commit": "4de93489ef01bf070f461915989be611156121dd"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Improve socket error logging and flushing", "commit": "1c53a90e00bd5ec8eacfeaeb386516cb470b8b3d"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added logging for incoming websocket messages", "commit": "312b9eeecaee5d16247a7f0c694e3168893c389d"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Simplify error handling and remove session-dependent code", "commit": "c6f43931664c01c597c642e35c64ec49f3008101"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Remove debug print statement", "commit": "5fd03efc301d722a5ee09c8b0cef6d04c1130fd3"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Handle WebSocket close events and improve error handling", "commit": "780c178d95a6dbe3fbd6b2fac18a6bdb16ec0b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Added logging for RPC exceptions", "commit": "cc3b896d2cd80affc251434800844829cc3fb6e1"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Use internal send method for RPC responses", "commit": "0a70e80668a598c909674c78b654b5ad8e6afce5"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Buffered RPCView methods", "commit": "bfdfa6c8bb27be4bd83bf8d4e3084e37ef0f7fae"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Use `send_str` instead of `send_text` for JSON serialization", "commit": "010f3b03a0983843c74219484e78c50d595da6e7"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Import json for RPC handling", "commit": "8f502af84eea60b5349fd1980d352f0f8e001502"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Ensure messages are flushed to console", "commit": "10c7232a8f6378eed8f5b4adecca8d582d57a069"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent premature WebSocket deletion", "commit": "88749ce05c7c4e9b5e238d16cb9fa4053f092fc1"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Enable websocket subscriptions after login", "commit": "2ae2e8450cad47031067f3baae3b09ff521c5c87"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Removed unnecessary style block in message template", "commit": "495543144d464121af0afab6545a5267ad561a57"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added highlight styles to web.html", "commit": "cfd3e7881eca77d10d32de2440a9d2b03aeaea96"}
{"repo": ".", "date": "2025-01-31", "line": "refactor: Streamlined message template rendering", "commit": "efe12644eda127170a3d60e086fa31ed940fca6e"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent default event on keyup and change events", "commit": "7526bcc816ffb759e3708f30167b4d3367955b64"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent form submission on Shift+Enter in chat input", "commit": "1999a6c8d8dd4fdbd48d5553a1704dfa065275ee"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent empty messages from being submitted", "commit": "ae5fffe5e0faf948a22feea0e651e08a0ed559fb"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Prevent submitting empty messages", "commit": "663ab415101e5da31fb71e3e9e3b433fbd6c3031"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Added query endpoint with security checks", "commit": "3796c7c54767b5de18c5310d20c9dd3c5aafdd0c"}
{"repo": ".", "date": "2025-01-31", "line": "feat: Made channel query asynchronous", "commit": "f6f99684307249b6650dcbbb3168db1ebfa71e73"}
{"repo": ".", "date": "2025-01-31", "line": "fix: Disable autoescape before linkify and markdown", "commit": "0c68c4e62255a307ecb48cba011ef38ace935eb3"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Allow underscores and plus signs in usernames", "commit": "4185bb3a69ac66d7b6614acf76bb5a2f613e0b82"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added emoji support to templates and app.py", "commit": "928969b8b6266298317ea4f7ca3e6b2cfbd42e82"}
{"repo": ".", "date": "2025-02-01", "line": "refactor: Removed unnecessary Z suffix from ISO date strings", "commit": "feeb94c9cf08ebee6d42165988b1d51030df4c33"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Added highlight stylesheet link", "commit": "e0ed4491b414c51b54e4c3ebd10cbebb46a903c6"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Add basic syntax highlighting CSS", "commit": "98d89dbc5f45a61ab6335e38d6e4a1df39bcc621"}
{"repo": ".", "date": "2025-02-01", "line": "feat: Applied highlight.css to message templates", "commit": "a06e3f404a15d8115fa65ba8533ff7774baa0beb"}
{"repo": ".", "date": "2025-02-02", "line": "feat: Add user data and audio notification check", "commit": "99fc9118b37f8564cd6e211d3d77ef997592f361"}
{"repo": ".", "date": "2025-02-02", "line": "fix: Resolve issue with user data loading and notification handling", "commit": "7d750db1f8235c8231699c2da39c1075ac678841"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Improved code display with word wrapping and line breaks", "commit": "3ae43c84e768a712fd2d0a8e65f52edd86bfa6a5"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in base.css", "commit": "23c8ebca73ac49c826434d40fd1e1fd2e3435957"}
{"repo": ".", "date": "2025-02-03", "line": "style: Improved text wrapping in chat messages", "commit": "38a24e9a12355f93776c9aef0b9caa5afb075531"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Commented out padding in message list for testing", "commit": "83cc0f613708ed27b928ba45b330c984d82dd546"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added padding to message list and hid avatar", "commit": "079187e1b460e5554bfec8b9658b5059cc3d51c6"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Hide avatar on message list", "commit": "f4a5536dcf1e27a7e8319488f8f39a8acfb818a2"}
{"repo": ".", "date": "2025-02-03", "line": "revert: Restored avatar visibility", "commit": "fe707dca4ea0bc2ecaedcda292f1ae636fce2b93"}
{"repo": ".", "date": "2025-02-03", "line": "feat: Added upload button functionality to the chat interface.", "commit": "b48a901e3385617d36511e251c4e7c62498e23bc"}
{"repo": ".", "date": "2025-02-03", "line": "fix: Remove unnecessary whitespace and improve text wrapping", "commit": "f395d1617394045cac7c41af0cd5ce9d6ef55ed8"}
{"repo": ".", "date": "2025-02-03", "line": "refactor: Removed setup.cfg and adjusted code for improved stability", "commit": "084f8dba2075aec93d9d88fd7cdd7f67fc63a212"}
{"repo": ".", "date": "2025-02-04", "line": "feat: Added drive service and upload functionality", "commit": "6f9adfe67fd551dd99746c40bb55706a7ffcef3b"}
{"repo": ".", "date": "2025-02-05", "line": "feat: Added :snek1: emoji support", "commit": "b6185a95f3fcbf539ec0ba767d4c0923092f8e82"}
{"repo": ".", "date": "2025-02-06", "line": "fix: Handle failed RPC calls and track success status", "commit": "203314b209030f297cd888685bb68721bc21c61b"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Correctly detect code language or fallback to bash", "commit": "386d9c3aaee80115241866ae72df9fad3ea3c714"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Handle empty code highlighting results", "commit": "d4aaa2d66be0a568eff8caf5ecef3c5826e6c67e"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Escape code blocks in markdown renderer", "commit": "a301e2c5bfb8286f63a48c2860162780f95e820d"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Updated html escaping import", "commit": "cfa2af61b81b613bf2b8177b8acff1ea8b7c8576"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Added language detection and code highlighting for markdown blocks", "commit": "51f1b1d86e4813c10e2750f0771c1bdcc1274bfb"}
{"repo": ".", "date": "2025-02-07", "line": "feat: Replaced navigation links with emojis", "commit": "9840c8eb03f969330583e9c3a7b28dbb5548f7d6"}
{"repo": ".", "date": "2025-02-07", "line": "fix: Prevent lingering websocket connections on disconnect", "commit": "7ca2bc5776213828a31c7fc237784a0a73c6f759"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Allow underscores in usernames and add user search functionality", "commit": "f291c0f2e4081fde4ed55d6cc25fdcbb1952af70"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Initial app template with basic structure and links", "commit": "fcb05903f3f583ce8532d65ee7edf1ad8df91df4"}
{"repo": ".", "date": "2025-02-08", "line": "fix: Relaxed username and password regex constraints", "commit": "8d0d709e18be0177b99f76f320eeb02b70bb41b0"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Added user search functionality with HTML and API endpoint", "commit": "d7b943dc8c8f485c975730d6054e32e67db36c91"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Implemented search user form", "commit": "49eb76dc8b93cd422a9fda40cece480a573b8524"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Prevent malicious queries by adding more checks", "commit": "60ca3ec7918073a2fb3ebe81e9ea733225391d99"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Style updates and search user form improvements", "commit": "5154811b29ced87375ac457fafeb25305f64a954"}
{"repo": ".", "date": "2025-02-08", "line": "style: Adjusted chat message container styling", "commit": "a8fea31a326c7b9868dde866be553bb9f84eee88"}
{"repo": ".", "date": "2025-02-08", "line": "feat: Optimize database performance with WAL mode", "commit": "06b539b8845c49ec6d2789876ef4069b8df77117"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection in UI elements", "commit": "b169fa4792e02303ea0f61e5d83b3993a8f72f05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent text selection on header elements", "commit": "ad4847a78e2945fe4f57ae16262e0c2a91a804f5"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent navigation selection", "commit": "bda5cfd52d5272742d147e93b506b87eeee04e1e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor search user view and template name", "commit": "afa40ada778c5d4102bce19312456adca51f70d0"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Implemented user search functionality with basic UI", "commit": "a42c2bdf5d2cee53c16e8dc123fc4473107ef203"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Prevent SQL injection by enhancing query validation", "commit": "e2a8efe5caac1ffaa70d6d7dc55e4e6b9741a35f"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added branding and updated view templates", "commit": "a3cec5bce0386c8c3012262aa4a582241786220d"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added WebView and channel routing for web interface", "commit": "78f9679f308016320b64cd49bb3552fb63d26d27"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added time descriptions and user switching in chat window", "commit": "e7cd397e0fe98074833e08880d915516718adaf5"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Added custom scrollbar styling to chat messages", "commit": "feb5234b3b581936d45ac328b23de7da8f375ee2"}
{"repo": ".", "date": "2025-02-09", "line": "refactor: Improved chat message rendering and layout updates", "commit": "ecb77cf361f0d55b512028701c67c1e347836e6e"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved layout updates after message insertion", "commit": "bfca2bdf734c9b9522186c1ff1b6479f93f34658"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Refactor CSS for improved layout and styling", "commit": "83121f7fa99df690a3b9029556aa023226cf22ef"}
{"repo": ".", "date": "2025-02-09", "line": "fix: Correctly append message element to chat messages", "commit": "dc2a31abeec3a85dab3c29ec270ae9fcf5ff2797"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improve text wrapping and code highlighting in messages", "commit": "cef83aefe7a4d2b37b9d4067d7482d9660a2dcbd"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message appending to chat messages", "commit": "a6555dc069b81c25ea6bd3f8f3e6132cb2a2ea29"}
{"repo": ".", "date": "2025-02-09", "line": "style: Improved message content styling and hyphenation", "commit": "661eba7161c1d869d73f04641878521fbdf8b72a"}
{"repo": ".", "date": "2025-02-09", "line": "style: Removed unnecessary block display from message content.", "commit": "e75836fe879e4f7e2a8bb34ed8ca901cc624ce05"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Improved message time display", "commit": "0f400a0b6aa04ffdfd8de1e26be3318a39a174a8"}
{"repo": ".", "date": "2025-02-09", "line": "feat: Enable CORS credentials", "commit": "49c0f932ab3e5705380a57cefa8da9ea7b9967d3"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implemented online status and ping functionality", "commit": "54c40c6b8586fbb9b2b639cd0c7aa1c72a6e53f1"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Display online status for channel members", "commit": "688e7fbf0e8977f442edc41cea1ac2a06f1ece40"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Increase online status timeout to 20 seconds", "commit": "48891c438694d37cd1b8338a2cc1f96f7647e77d"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Include last ping in online user data", "commit": "087ab1a8a55ae58b52078dc5cb7de7db65132e84"}
{"repo": ".", "date": "2025-02-10", "line": "feat: Implement online user status indicator", "commit": "3f75c8d5f9a67e68cf311ac5b9c60f13a3aa6493"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added private chat functionality with DM creation", "commit": "8a59ddd210bb3ab3d29f9207afbf887988b528d9"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "ca463b79a88687a76d9fab851b8f2ffc7e071e81"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and updated tag to lowercase", "commit": "8fe24f711cb3e796471145a086c4b72289e12e1a"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implemented private chat functionality and redirection", "commit": "bfe4b351c1fa750c9d12d3ae880928cca0346bba"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Implement private chat functionality", "commit": "be35a6caf07c51eaf79625ad914215bebb9b11c5"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added base URL property and file type handling for uploads", "commit": "2cfb8fe3085f6c592488e81b3acf2dbb6f0ac420"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Display uploaded files as links instead of iframes", "commit": "2541fc536aa45630ec55297c58787686e4154fab"}
{"repo": ".", "date": "2025-02-11", "line": "feat: Added echo endpoint and noresponse handling", "commit": "b6eba608435be4d798e50328f9149a8768a5cc8e"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Added channel list and updated templates", "commit": "3baa6e53df459c3816958fbcd4cc6d4bbd1a8fd0"}
{"repo": ".", "date": "2025-02-13", "line": "feat: Add channel list and improve DM user display", "commit": "37da903936e4ab85fae254421c356966991d53e4"}
{"repo": ".", "date": "2025-02-15", "line": "```\nrefactor: Improved socket communication and removed unnecessary prints\n```", "commit": "1f8ebf71d0c2f7f1460ba7a1b6113831e4148edb"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Refactor socket handling and messaging for improved user management and broadcasting", "commit": "53be4b060a1fff9cf58c7224dc4522bb0cafa852"}
{"repo": ".", "date": "2025-02-15", "line": "feat: Add channel tag to RPC view data", "commit": "9c3abdec2613c4d492c363cca8a07882dd3d8135"}
{"repo": ".", "date": "2025-02-15", "line": "fix: Correct channel UID retrieval in RPCView", "commit": "d1396801c05688e15ad7f1082dab2576b9a2b011"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed YouTube videos directly into links", "commit": "7c4334fe7b5b7e6ba44627a4f084638af51dd44c"}
{"repo": ".", "date": "2025-02-16", "line": "feat: Embed media, images, and YouTube videos in links", "commit": "c463dc6dca38348f9a54189e0b6eff6f5a3eb9b2"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Correctly linkify HTTPS URLs", "commit": "263595fc7e7f86ec5d34b967b52c3d0a57dbc5fc"}
{"repo": ".", "date": "2025-02-16", "line": "refactor: Remove unused YouTube embed code", "commit": "7bcc67c6d35484c0fb8ddf201ea5b23b533d99d3"}
{"repo": ".", "date": "2025-02-16", "line": "fix: Handle file extensions in upload URLs", "commit": "be956a13db0941008802701196ca5e3870ebf2aa"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Ensure images, videos, and iframes within messages scale properly", "commit": "ea4196af8f7d7e0c97c004a07817bdc1dac999f5"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improved media handling in message content", "commit": "f28be3ba55cbe9c1b20f70b4e1e8e2668eb2388f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement scroll to bottom on new message", "commit": "2e69ac5921c16ea0cda1a1b7c84dd63ff458db62"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement timestamped pagination and focus textbox.", "commit": "8c33bc63d6cc623d0782f14c96a42c612accbc75"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "477ca5917a59d3720a6a5ae01b307dec1a74cfaf"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "9e3b9ae326b6ec632f3280018ea68d1896645b9a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use offsetMessage timestamp for infinite scroll", "commit": "33bc695cda6bf5889d802129117ed59992b87143"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected method name for fetching messages", "commit": "aa5703e62f891fa7db09f07e6ce060875f5990d3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected offset calculation for infinite scroll", "commit": "1792686531fb440d9f453422bfa648c890c255d1"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll offset for infinite loading", "commit": "3ee7c6d8024245933569fdaf3f99a71afd14fe8f"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for chat messages", "commit": "95a8a458420dd2ebdbd6f7c03bd58c649985933e"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "162f89f9d0f7e304d355dd8f626ce2430dd840bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use first message timestamp for infinite scroll", "commit": "104ee277669ee2cd55eb97b674f7a2432d31bb5a"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Prevent loading extra messages when not scrolled past half", "commit": "60efe6ee8a158cd671cbd05c20c7d382d6dcbb3b"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling based on scroll position", "commit": "2fb6be753efa7f2cc1e0183551a1ad655388b970"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "24cd378c9d8f857f4f28af1069a2f5523a6441d8"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Use scroll instead of auto for chat messages", "commit": "8b98935d11496d51a0007db4b78391dde7a69163"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar and improve chat styling", "commit": "5b03ecda3f3f9ae515dd00a4e421255535a2f215"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Hide scrollbar in chat messages", "commit": "3230c9f93bf9c3ae1b27474eac1cfc35f626d387"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Improve infinite scroll trigger position", "commit": "6c58f4b26c628881aee5cbe0597ba489f705e42f"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjust scroll trigger for infinite scrolling", "commit": "bc8a296223f3a2c6e07b126a78373aa5bb40399d"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll position threshold for infinite scrolling", "commit": "c77d2fb782258f787c5b52e3b27c5a3b0d468903"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Adjusted scroll threshold for infinite scrolling", "commit": "2e86ca2a3f1a4a8c746eecb46038a216b9706cdf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Disable unnecessary scroll prevention", "commit": "1a608d8cfb1a3dcef9591b214fc54904615148bf"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Enable infinite scroll when near bottom", "commit": "f0d76bd46af06637a21526c58d97f4b4d57f87dd"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Disable scroll loading for now", "commit": "1b6ebf50080b0b86256e639031f36da92b8990b2"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Correctly trigger infinite scroll when scrolled past halfway", "commit": "6bdc6a7347a492f155629458c9b277cc16e04666"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected infinite scroll logic", "commit": "c042af8b800879ecfbb817089119aab75d839c32"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Implement infinite scrolling for messages", "commit": "bb2b4b61b49bf4ba38d75bcbb0d751961c49cfa3"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Corrected initial message retrieval for infinite scroll", "commit": "2595594c3a99f6613e8e2194977fb1707c9f8b98"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Added comments and improved message loading logic", "commit": "6555e4f8266b01963cfd660a4e175b01ab615c0c"}
{"repo": ".", "date": "2025-02-17", "line": "refactor: Remove unnecessary comments from web.html", "commit": "2ab4341d0099799a84c0df6d91de33e1c5f69470"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Update manifest and app.html for PWA support", "commit": "e21880b4f5fd15259e09c207ce54d8e86bd61ac7"}
{"repo": ".", "date": "2025-02-17", "line": "fix: Remove unnecessary display_override from manifest", "commit": "6c7266f20403f1f190c8b41f22d653c041dbbc77"}
{"repo": ".", "date": "2025-02-17", "line": "feat: Added 192x192 icon to manifest", "commit": "c745f609976397de0fb0cf7dec80205239b44b87"}
{"repo": ".", "date": "2025-02-18", "line": "refactor: Switch to asyncio for application startup and debugging", "commit": "3ccbe8be5c604d2683cf55553d1f11c674f6b930"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Standardize environment variables in compose.yml and add logging to app.py", "commit": "ebb520dd4a80b513d1eb6fc6ce90e6b46f905100"}
{"repo": ".", "date": "2025-02-18", "line": "feat: Integrated profiler for performance analysis", "commit": "c6620ad70afce9407c16793de8ab4fea35523d81"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Sort profiler stats by query parameter", "commit": "91a21db89b6d7bc36b5525f9d3a07d1ebe2a4ad3"}
{"repo": ".", "date": "2025-02-18", "line": "fix: Corrected typo in profiler sorting", "commit": "60404c6fd31894f3fbb6ce31ba48f1750101748f"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Added a1 emoji and long emoji\n", "commit": "736123c4aa313c51a7e0daee8cdd6dc7583547fd"}
{"repo": ".", "date": "2025-02-19", "line": "feat: Increased Gunicorn workers for improved performance", "commit": "2ad5a7b1f49704baf7b890fcfda7a87fddd456f7"}
{"repo": ".", "date": "2025-02-19", "line": "fix: Reduced Gunicorn workers to 1", "commit": "e06824f4ec703388b7d55beeb5f1b3ef12452226"}
{"repo": ".", "date": "2025-02-19", "line": "refactor: Increased Gunicorn workers and moved app instantiation to global scope", "commit": "821db3cb1a67c20a968ac1dd8ecc4263e511cf16"}
{"repo": ".", "date": "2025-02-20", "line": "feat: Improved database indexing and UI enhancements\n\nThis commit introduces database indexing for improved query performance and several UI enhancements:\n\n- Added indexes to `user`, `channel_member`, and `channel_message` tables.\n- Updated CSS to include a container with improved styling for lists and links.\n- Modified `manifest.json` to set the scope to `/`.\n- Refactored `template.py` to handle image embedding and YouTube links more robustly.\n- Adjusted `app.html` to display \"Channels\" instead of \"Chat Rooms\".\n- Enhanced `search_user.html` with a container and improved styling.\n- Sanitized user data in `rpc.py` to remove sensitive information like email, password, message, and html.\n", "commit": "3623286a9dfba330612c42e579abcca63ab186ed"}
{"repo": ".", "date": "2025-02-20", "line": "refactor: Reduced gunicorn workers in compose file", "commit": "a7e0e5a3f821d51eb4e2ecde82baeb8ee0e183c7"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Added sound notifications and mention sound effect", "commit": "54920e1545ffc68e2f928d3d042f5f11080f0d41"}
{"repo": ".", "date": "2025-02-21", "line": "feat: Implement notification sounds for different events", "commit": "8ea41bb592b86e2f49b2f838e03006bc04472da5"}
{"repo": ".", "date": "2025-02-22", "line": "refactor: Moved sidebar channels to separate template and added channel notification", "commit": "fbe95d6631dfac2edc4c8600922020be4e15eccb"}
{"repo": ".", "date": "2025-02-22", "line": "feat: Add channel sidebar with message counts and highlighting", "commit": "076fbb30fb51ecfb5b15d394c760f55dac26e1c1"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Added avatar support and updated message template", "commit": "5af4e5754b6902ae13c798d9793281d62b684590"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatar UIDs", "commit": "e280e8776457605bbb5548fe9de5328b7b04bb8a"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Generate unique avatars", "commit": "162cfe394558a085894a11cea57b193d6108b90e"}
{"repo": ".", "date": "2025-02-26", "line": "feat: Updated welcome message and registration buttons", "commit": "da1be6301c79cf76383e6568f91ee23bdf5119f6"}
{"repo": ".", "date": "2025-02-28", "line": "fix: Disable login requirement for avatar view", "commit": "66b85d146abac25df83edc1975db209b9d43fae7"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add last_message_on to ChannelModel and update on message send", "commit": "4620ebb800b5dd848ec28713f1afa20416698922"}
{"repo": ".", "date": "2025-03-02", "line": "feat: Add index creation with error handling", "commit": "e469e27abfedc0b08b483e0715b4dc9b16240c5e"}
{"repo": ".", "date": "2025-03-03", "line": "fix: Correctly format upload links and strip trailing commas from link targets", "commit": "45e3239cc06cdab0a8e5c1c1ef56593f65e750ea"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Implement push notifications with service worker", "commit": "c3c94461c295ef4c6219051369472f983267437c"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add notification read functionality and unread stats", "commit": "578c182f2707a5f5b7c93f421e2035f7271aa60c"}
{"repo": ".", "date": "2025-03-05", "line": "fix: Handle missing notification model in mark_as_read", "commit": "580ec5ab0d57a542ab38b25a6c508804d5bcfa21"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add new_count to ChannelMember and increment on notification", "commit": "84d7b11f24b37cdc41ce9d5bb24be4080af14be9"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display new notification count in RPC view", "commit": "d7851e645785fd707a7c7fdc5b6fff036e0c80f7"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "e1324e99bf06018a804a1a3dcc83f96cde04b1af"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment new_count for channel messages and members", "commit": "afbf53938bd59e1e03dfc011063b27134dd0c054"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Added new_count to notifications", "commit": "8d3d7327d777ae3150ecaa237e137f8221310dfa"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Remove unused new_count field from notification model", "commit": "4df6055566d61c769ae1759d81900d138093136e"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Increment notification count and log insertion", "commit": "dd11c8da5acc5ed97a278a705e7282edc7d50bc5"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Add logging for notification and mapper updates", "commit": "8f137cf8e6d67a8e73ee221d2f9192b7a3ab431d"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Allow channel members to be created", "commit": "30fe0bfae7f2bc9badcb16f734655e661fe976ad"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Return model directly from query", "commit": "edb35b57070a5659e8491a967880d816f2d07697"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Display username when inserting notification", "commit": "0613f6f54de9247c17b4bae924197ffb4cbd2966"}
{"repo": ".", "date": "2025-03-05", "line": "feat: Disable banned, muted, and deleted checks in channel member query", "commit": "1807cff67d3a4306f122d7df4436cc88137f299c"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Implemented threads view with basic message display", "commit": "5b78165fb7e83f7e8a45f790c0f6e5a9fde758a0"}
{"repo": ".", "date": "2025-03-07", "line": "feat: Added threads view and related model/service updates", "commit": "a1afaacb2ea6ded2eb14eb6d8fbdaf34708d9568"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Prevent race condition when reconnecting socket", "commit": "e3afc1ba6e97378688027a60d6d98cc19a519a8c"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected avatar styling in threads template", "commit": "37f6725f2f7e36ec03416f191c9d16cd864991ea"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Display user avatars in threads", "commit": "095be5892db198d0a6356c8700ed0c038e419a29"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improve thread avatar visibility", "commit": "9292e3b8f3b64084d6bcc0b13dd42d015f4799d9"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve thread display and opacity", "commit": "24260f9c371ab2d989441e391f513f6460eaa1ec"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Unified styling for chat messages and threads", "commit": "1b72063a5b972dd726c647b7397f0ced16bd66c2"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved thread display and DM name handling\n\nThis update enhances thread display by adding a name and color, and fixes a bug in DM name retrieval. It also refactors the code for better readability and efficiency.", "commit": "5a72c8cd83d1374ff709556cbf4b4ddbf7a9ab7a"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improve message display with rounded corners and avatar visibility", "commit": "98c2213a862b253f8f967f428b0b248bbe3a32f7"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Improved user search display with thread-like layout and real-time updates", "commit": "0a9b66d2f76a2c4418db7149b17729bf8a2dc811"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Set default page titles and update login/register titles", "commit": "6ecd356cc08e17596ff6b5007c46def2bc17c851"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Improved form submission and change event handling", "commit": "804556b74d8caa5e3a79a03cd1a8d7870843b898"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added meta information to base.html", "commit": "7c22a70722db3fa97a813c30c01c4cd5462138eb"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort threads by last message timestamp", "commit": "8e195a49e3e914a4b241e95378bd9a07611715a8"}
{"repo": ".", "date": "2025-03-08", "line": "style: Improved form input focus and placeholder visibility", "commit": "c9113ca09500c5b3cc277fb09b9607a505d39f30"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Added head block for custom HTML head content", "commit": "62aa15a4b4d6514824378cca73084c9ce2df903b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented back button styling and layout", "commit": "ad7eab9717848584369ded6e19babb6a7b9f5b98"}
{"repo": ".", "date": "2025-03-08", "line": "Revert: Undid auto formatting", "commit": "e91ae4584aaf08cb02b89cdf26b8c43a8c8179b0"}
{"repo": ".", "date": "2025-03-08", "line": "Merge main", "commit": "dd5a9a23e8e452a03f7080656608617309bf73a5"}
{"repo": ".", "date": "2025-03-08", "line": "Merge: Tweaks for login/registration and base + image roundness", "commit": "aedfe9aa947dcd2262c825af5a4d977eb298ccb5"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Sort channels by last message time", "commit": "a219ce4d79a15ef900583ab025fb0da1df79ace3"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Filter channels by privacy in sidebar", "commit": "d6061cb45b68a7b393b0e35a560d5d7bea4b9478"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message, handling null values", "commit": "24a504e3a7383c7a338fbe3ee09411547eed58eb"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Sort channels by last message time, handling null values", "commit": "11b8f0e744fb9d6b05ce11b7475bb3f51edee96b"}
{"repo": ".", "date": "2025-03-08", "line": "feat: Implemented form preloading and autofocus on the first input element for login and register pages", "commit": "0266b2a559952d0ff767b251c2921704a6aa1abe"}
{"repo": ".", "date": "2025-03-08", "line": "fix: Corrected semicolon in loadForm call", "commit": "fd07001983fc3d3015ac7064461c14b8486155e6"}
{"repo": ".", "date": "2025-03-09", "line": "feat: Preload form and autofocus first input", "commit": "d9ac1813ba8ddad9fb602730cb2cc763aab4bc23"}
{"repo": ".", "date": "2025-03-09", "line": "fix: Sort threads by last message, handling missing timestamps", "commit": "91d8f3efd16431fe99b0e60927d1f6d9b6587f7e"}
{"repo": ".", "date": "2025-03-10", "line": "fix: Improved search user page layout", "commit": "c4e3f1fc1f10e4d98fc04e4928c62c88385fbeb8"}
{"repo": ".", "date": "2025-03-11", "line": "feat: Improve file download experience with suggested filename", "commit": "c6c2766381f75b058fb61f91556788b0720b058b"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added reply functionality and improved time display", "commit": "5cfcafe0821b3cceb753b9ddb4076a79f26a88c0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Display creation time on container", "commit": "c55927aa9c7e575544901bbee41cb9a9d3c6437a"}
{"repo": ".", "date": "2025-03-13", "line": "style: Darkened the overall theme.", "commit": "0fad298fc078e2a3ea1afee71ee92b99b83427b0"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Added padding to links", "commit": "0f950218d6d783c4738966e15b2746c14043c82f"}
{"repo": ".", "date": "2025-03-13", "line": "feat: Improved reply formatting with markdown and blockquote", "commit": "d8b43dbd08afa8c4498bbc5611dd6e7d61f9b139"}
{"repo": ".", "date": "2025-03-14", "line": "feat: Increased update interval for times", "commit": "17c9731b9f8be07b247aeed29ba2ab1319e408f0"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Centralized input styling in shared.css", "commit": "5b70bb9ea5cc6637ac585cf8f04efd4cde0aa621"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Applied updated input styling across pages", "commit": "752f3df13a548d22646f87a87940dc64e15587f3"}
{"repo": ".", "date": "2025-03-15", "line": "feat: Refactor app modules and update script tags to use type=\"module\"", "commit": "a4d79b06c49ec9f605336bf181e98455c8acd460"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Convert files to modules", "commit": "a9663c8170dd2f925100eaa50e8c0019c5eee683"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Refactor socket and event handling for improved reliability and structure", "commit": "4a8a614adb5e15cad18414214d30ab83464eae14"}
{"repo": ".", "date": "2025-03-16", "line": "refactor: Improved code formatting and consistency", "commit": "c9c070c497bb9af3eb5bb9915f221ec00b56b832"}
{"repo": ".", "date": "2025-03-16", "line": "fix: Reconnected socket on error", "commit": "e62a8554090009c7914b95833066ad46251da01d"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Changed button colors to dark theme", "commit": "819cf8381c287e2ee88fb1d6fe789e30a1a33eff"}
{"repo": ".", "date": "2025-03-16", "line": "style: Adjusted upload button background color", "commit": "2ba28c193a826e8c1f9647f06bffb6084166c9f1"}
{"repo": ".", "date": "2025-03-16", "line": "feat: Added Umami analytics tracking", "commit": "287e10d8aa8feb2590ad48d9412ace74a9432baf"}
{"repo": ".", "date": "2025-03-17", "line": "fix: Scroll to the end of the message container", "commit": "54416ee84f88064897a824ae2c3a9e0ef2c1ccaa"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Refactor header layout and logo display", "commit": "39fa8fa0cd7a725edc1f293a7e8d0ea0d5d5bca6"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill and update templates", "commit": "7fa2817f773b47737236f4bb700023bcf8b483f1"}
{"repo": ".", "date": "2025-03-17", "line": "revert: Removed unnecessary formatting changes", "commit": "965dc930a900a5080e225bb492be2b799daed22f"}
{"repo": ".", "date": "2025-03-17", "line": "feat: Add Promise.withResolvers polyfill", "commit": "825ece4e7868be28ba03c4fde5149695b0dd9dc5"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Added dump script for public channels", "commit": "3c6a0944d68ca16250ec9364d7f006b0e7eea6e8"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Improved dump functionality with JSON output and user information\n\nfix: Removed unnecessary print statements from cache and dump modules", "commit": "70db15bf27c7a8fd8bf112432f02754f01bbb3d7"}
{"repo": ".", "date": "2025-03-18", "line": "feat: Refactor dump script to output to dump.txt and improve message formatting", "commit": "3960390ec45f427979ebd2b81c1a21666a47e71d"}
{"repo": ".", "date": "2025-03-20", "line": "refactor: Update session management for user registration and login", "commit": "5ba239caa8928a078362af0e6e2d1a4626bd508d"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and updated Dockerfile and compose.yml", "commit": "7dcabde2ed0ab699ea3033f7788381e85c352b97"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal access and basic shell environment.", "commit": "013d4adce57f4afec5176bbdb5e2225d529ec3b7"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Added terminal page with Ubuntu option and channel sidebar", "commit": "6e68408ddfd8be2376f7453deac8f63a6bfb93e4"}
{"repo": ".", "date": "2025-03-22", "line": "feat: Add channel list and user context to template rendering", "commit": "604e27ce10dee59b1b3f6ebd359ee356b085df2a"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c72b015073347e86f2edf1e67544b1ae31e929b0"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Disable r executable and add vim and htop to apt install", "commit": "78c631e6c79b4b69b0e202a301b710c4150e6dbe"}
{"repo": ".", "date": "2025-03-22", "line": "fix: Corrected execute permissions for r script", "commit": "c8461342fd8d260da69c84f27f9e9d13b3430942"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Introduce ThreadPoolExecutor for asynchronous task handling", "commit": "0bc24e8d2ef4452efc7be5286288a2531908ea55"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Improve terminal display and handling", "commit": "c2d9af807a95900c4fecd7a1929c8d92393a955d"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Install git during setup", "commit": "c5c160baae67d7e5932963f8501ed7d56dc35c21"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Updated drive functionality with new views and services\n\nThis commit introduces a new drive feature with the following changes:\n\n-   Added DriveModel and DriveItemModel to represent drive data.\n-   Implemented DriveService and DriveItemService for managing drives and items.\n-   Created DriveView for displaying drive contents as JSON.\n-   Updated Application class to include drive-related setup and routes.\n-   Modified Makefile to use python3.12\n-   Updated src/snek/app.py to include drive view.", "commit": "7b32a7eba4d5944142a3b40616d37b0862087371"}
{"repo": ".", "date": "2025-03-23", "line": "feat: Add URL to drive items", "commit": "dec2281ac88d151afda016fc01e833dc2f0aa89e"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Correctly fetch file extension from item", "commit": "1de2c55966c0ddf0fb663b935427d2c005f0fde9"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Improved terminal session handling and websocket integration", "commit": "529606955a545bc25cf5899f2c79d3660bcefd54"}
{"repo": ".", "date": "2025-03-23", "line": "fix: Increased websocket thread pool size", "commit": "af4a70e8949bfde704a0499177050fdeab5300d9"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Display user color in sidebar and update new message count", "commit": "5390b8bdc3dc04645258ed758f3894de00008e80"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Prevent errors when user data is missing during notification updates", "commit": "877ef7970d5b7bb5f8fd0af35adad5d8d071b14d"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Remove debugging prints", "commit": "87c189b3fe897cc15ee6ac311e987bf9fe7811b9"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Cleaned up console output by removing unnecessary print statements", "commit": "71a206a19fa6b2a2ae05d886a39d2fa03f2c0f71"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction for notification creation", "commit": "bd5bb5ae65d6cb870d090382b106b705833d4cf1"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Persist RPC transaction changes to the database", "commit": "13ce09a5c50ddba8956dbeb838f9dd1bcafe184a"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task runner and task queue for asynchronous operations", "commit": "145373399dada2f4cee54af0c99e62d4a27a0f99"}
{"repo": ".", "date": "2025-03-27", "line": "refactor: Improve task management with asyncio.Queue and error handling", "commit": "e6f702a6b405f1dae0ce719177c6f7bf5b636ad8"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Remove unnecessary database transaction block in RPCView", "commit": "9d5815ed1028354ac61f2d506530147a02c476e6"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added database transaction management for task execution", "commit": "8810679fd8ea2dd6e63b09bf9a4b9af5c2d0fdd2"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Added task execution time measurement", "commit": "6ad3844f037735e268b42247fcc6e8605cc13f07"}
{"repo": ".", "date": "2025-03-27", "line": "feat: Schedule notification creation as a background task", "commit": "73e8779bdc42ec5f5618fad3d563544d1fad2b69"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Ensure notification creation after socket broadcast", "commit": "6f043d21390d13d37772c8ae145d7a66b3919529"}
{"repo": ".", "date": "2025-03-27", "line": "fix: Await task creation in chat service", "commit": "9e50b2a6d3570c4f86fcfca6a5ce59947c0105ec"}
{"repo": ".", "date": "2025-03-28", "line": "feat: Initialized Ubuntu Dockerfile and terminal environment setup", "commit": "5fbcadad8bad7b8dd7ac3279938ff50db6b5d380"}
{"repo": ".", "date": "2025-03-29", "line": "fix: Adjusted avatar size in message template", "commit": "fe1b3d6d191176111538f1ba06018e14c44ca8f9"}
{"repo": ".", "date": "2025-03-29", "line": "```\nfeat: Added webdav support\n\nThis commit introduces webdav functionality to the application.\n- Added webdav dependencies to pyproject.toml\n- Implemented WebdavApplication class in src/snek/webdav.py\n- Integrated WebdavApplication as a subapp in src/snek/app.py\n- Added necessary imports and configurations for webdav integration.\n```", "commit": "29139d5d0c18ad2b0ebd32db9a0b629c45c0a651"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Initial implementation of WebDAV functionality with basic operations", "commit": "9a923f6bddd73df27af80ef6c8e2313816a07a48"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implemented basic WebDAV authentication", "commit": "886d21999c716ee306318796f3f159ba085f9618"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Use home directory instead of drive for user folders", "commit": "6dca3a96e1cd23cc26d69aeee31ae45e14977bbd"}
{"repo": ".", "date": "2025-03-29", "line": "feat: Implement recursive node creation for propfind", "commit": "3926b2d837bef181546d826b41821cf90fc755a8"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Updated home folder path to drive and removed lock implementation", "commit": "d5917b94540aee206935354f438a6a7f893278ec"}
{"repo": ".", "date": "2025-03-30", "line": "refactor: Remove unused LOCK and UNLOCK routes", "commit": "8058e4a4b0aee254edc76fa28b2c4336eb393c4b"}
{"repo": ".", "date": "2025-03-30", "line": "fix: Handle potential errors when creating user home directories", "commit": "2a47c0ba5e7344d44c3acd15d8f3efeb11722677"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added settings page with profile, notifications, and privacy sections", "commit": "32e0c959e8589f574d1c46b96d35f49c98721566"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Improve responsiveness for smaller screens", "commit": "87b48af551d2ed023f77ca57d033ed0079d303f3"}
{"repo": ".", "date": "2025-04-01", "line": "refactor: Reduced message list height", "commit": "18b1ec20b67522cf816b29f3cde64a935ec5b330"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Removed chat window element", "commit": "7c52c2d9d5f10623ccf479918ea5baed247a07b5"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Added footer styling for improved layout", "commit": "fbd4fa4e668628c11d8b592718cfcdcca71a3c0f"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Style adjustments for mobile view", "commit": "d24627b35fd2201e6baad781a11fbae0c379f366"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted width for mobile responsiveness", "commit": "e3c997302b07228c0791d6307b429109b6eb3d53"}
{"repo": ".", "date": "2025-04-01", "line": "feat: Adjusted form width for mobile responsiveness", "commit": "27c0abea3147e5e00a5196fa9b351141a9d17ae2"}
{"repo": ".", "date": "2025-04-02", "line": "fix: Handle missing last message gracefully", "commit": "b365afc910522a484ad257af6df7b607712c71f2"}
{"repo": ".", "date": "2025-04-02", "line": "feat: Add unique request ID and improve UI responsiveness", "commit": "99b2beeab0d55242537b7ec810bfdd69feb47103"}
{"repo": ".", "date": "2025-04-02", "line": "style: Adjusted layout and overflow properties for improved responsiveness.", "commit": "81479e7058feda9954fd74810d1294fb92e7a1c4"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Refactor settings view and sidebar", "commit": "d10768403d221fbd1d50a520c083b8d1be1b3a19"}
{"repo": ".", "date": "2025-04-03", "line": "feat: Added profile settings page with nickname and description editing", "commit": "69482207461eec9c3c64ec297231989aa248dd9a"}
{"repo": ".", "date": "2025-04-06", "line": "fix: Update icons in manifest for stability on Firefox Android", "commit": "c2b8061ac292f18949d81c660c5a314cb42bcc6e"}
{"repo": ".", "date": "2025-04-07", "line": "fix: Resolved web manifest icon instability on Firefox Android", "commit": "75593fd6bb45ee6020e54f3e0de9b1ff0e6d4f5d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added IPython dependency and improved asyncio handling", "commit": "d71d5da6bcf22d2daf5ec59832f15fe02472b95c"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Implement user property service for storing and retrieving user-specific data", "commit": "d2e2bb811707b02f05cbf22d10ef1916b021c90d"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Added debug middleware and improved routing in WebdavApplication", "commit": "d23ed3711a464f1d796ed35e58dbcaf1db6b7d84"}
{"repo": ".", "date": "2025-04-08", "line": "feat: Add debug middleware and improve WebDAV functionality\n\nThis commit introduces a debug middleware for request logging and enhances WebDAV functionality with several improvements:\n\n-   Added debug middleware to log request details.\n-   Implemented caching for file size and disk free space using `time_cache_async`.\n-   Fixed potential errors in user authentication and home folder retrieval.\n-   Improved error handling and logging.\n-   Refactored code for better readability and maintainability.\n-   Added support for asynchronous file size retrieval.\n", "commit": "13f1d2f390afdfc912d24bb63930c9ca47e05f94"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Support short and standard YouTube links in template", "commit": "b31c286a8b8442d48f9b0713a8cce41432c168d1"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add YouTube video embedding functionality", "commit": "b0a97ad267b971f8ba298bb5a0e696810c08b026"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding with improved parsing", "commit": "c6575d8e525dfa2f574e1965cd3df4379cde7acd"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube video embedding logic", "commit": "6138cad7827c48a86b20d4015dce818dca348f04"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Style YouTube embed to center horizontally", "commit": "087f9c10b44fca9f29de862562b405ef5586f151"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add support for YouTube video embedding", "commit": "e6bd7aa15211ae0bd3be65be3a659526b1131eee"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Added video embedding functionality with improved layout", "commit": "94e94cf7ca4bdcdd581dfe074728e93412c2a621"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "6673f7b615508f0c344fe0efbebe362f5236bd84"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding from text", "commit": "2582df360ab0667a3d29c46b92ad4abeb397d363"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Add video embedding functionality", "commit": "656ea5f90ee56a16b0f0047cace848572dc479c7"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Simplify YouTube embedding logic", "commit": "c529fc87fd6ed7b39bf057bce44ef30d1bc17f1b"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Implement YouTube video embedding", "commit": "8fa216c06cfaf3cd249e6c44efb5e5b2735f8c6a"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Refactor asyncio and database preparation in Application class\n\nfix: Corrected indentation and added missing executor assignment in prepare_asyncio method.\n\nfeat: Improved form element structure and added missing attributes in profile form.\n\nfix: Corrected user property JSON serialization and retrieval.\n\nfeat: Added UserPropertyService import to service initialization.\n\nrefactor: Adjusted YouTube embed template for better compatibility.\n\nfix: Corrected user data retrieval and handling in SettingsProfileView.\n\nrefactor: Improved file size and disk space retrieval methods.\n\nfix: Corrected lock token parsing in WebdavApplication.", "commit": "44dd77cec5639575cb86973eceb8d174d570370c"}
{"repo": ".", "date": "2025-04-09", "line": "feat: Minor formatting adjustments across modules", "commit": "743593affe276ae8ffd3751c80fe88eb4c99ac7f"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Removed comments and added channel display", "commit": "0e6fbd523cd4f4279a4f230567504b30c9b3116d"}
{"repo": ".", "date": "2025-04-10", "line": "feat: Improved channel broadcasting and added user UID retrieval\n\nThis commit introduces several enhancements:\n\n-   Added a `get_user_uids` method to `ChannelMemberService` to efficiently retrieve user UIDs for a given channel.\n-   Refactored the `broadcast` method in `SocketService` to utilize the new `get_user_uids` method for improved performance and error handling.\n-   Added error logging in `RPCView` when deleting sockets.\n", "commit": "3594ac1f5984953487e0c3423c9672b01e416c28"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Add stats view and caching statistics", "commit": "bc65752ea252cdcd929ba0bd956455317958337a"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added stats view endpoint", "commit": "a1840cd034e7a4c792e2bcc69ff06595b1e2add3"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Moved 'r' executable and updated .bashrc for automatic updates", "commit": "22668f8a72994446ffaa109e5ae742bd61bd3bf2"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Added initial .rcontext.txt file with facts and work procedure", "commit": "ec9af49f2903682cea978db15422fba4624c488d"}
{"repo": ".", "date": "2025-04-13", "line": "docs: Added a reminder to be rude but functional.", "commit": "9b49e659e575e99de717a5c64e1ba1c3c4039cb1"}
{"repo": ".", "date": "2025-04-13", "line": "refactor: Improve process handling and error management in TerminalSession", "commit": "823892a3021e674fea933b717565518dc1696031"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Return empty list when search query is empty", "commit": "4a770848a6dbc558c029b083a881becf7adef8d7"}
{"repo": ".", "date": "2025-04-13", "line": "fix: Require both logged_in and uid for login_required views", "commit": "e4b0625799d9efd89e7e9518278588158b296c6c"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Require login for search user view", "commit": "8ae9aac045e41fc84ebab102335a2613b3e22c08"}
{"repo": ".", "date": "2025-04-13", "line": "feat: Allow profile description editing and nickname updates\n\nThis commit introduces profile editing functionality, including a textarea for descriptions and an input field for nicknames. It also fixes a bug in the user property service and updates the profile view to handle form submissions correctly.\n", "commit": "bee7d828cd67581c33946630cd22fe8edd674d15"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Add user profile view and link avatars to user profiles", "commit": "3b05acffd296169eed305a55dba79d632d5f78f5"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Implement user profile view and template", "commit": "a3abd854bbf0ebe2ef0ef46e7c346a995e5b6faa"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Refactor user property setting logic using upsert", "commit": "9fb6e64655dff132be43e2fc867827d17ad94201"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Improve user profile rendering and link avatar", "commit": "0fa04883850534fbb97755e06ebec1538dccfdc7"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Use user uid instead of request user uid in user_property.set", "commit": "d4f5a4640929b1f16ccddc741f977cf7e901e7de"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added navigation links to user page", "commit": "3cfb79c8f560430639fceb4a278fc81dfbad2299"}
{"repo": ".", "date": "2025-04-14", "line": "fix: Removed fixed positioning from header on smaller screens", "commit": "c36ce17da5fcccfdaaf7ddf7579b0399519d078f"}
{"repo": ".", "date": "2025-04-14", "line": "feat: Added chat area class to user profile section", "commit": "4cc70640e4f7c4d65e5a0c3a503aae1f891164d5"}
{"repo": ".", "date": "2025-04-17", "line": "feat: Refactor layout and styling for user profile page", "commit": "1cd0b54656a969bcb87cbdc07687866ca43e650b"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added command-line arguments and executable script", "commit": "46a8b612b49f1094c0a8520d97d4b5642f2a57e9"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Configure setuptools to find packages in src directory", "commit": "061da150f9779c6130fac0c957d4facdd59aa33a"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Handle missing pty on Windows", "commit": "6312dfae47b09753675b038798cf38f00311e772"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added UUID generation and updated hashing functions", "commit": "c709ee11c99f54b58844165b6eb9993240ab0005"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Added paste and file drop support for messages", "commit": "f7fda2d2c951a07bccc927a333b7feaa527556c2"}
{"repo": ".", "date": "2025-05-06", "line": "fix: Use user home folder for uploads", "commit": "529ebd23fc0b50e2606ecddc5e1199774dd18384"}
{"repo": ".", "date": "2025-05-06", "line": "Merge feat/copy-paste-drag-drop", "commit": "0f6eb5c043325e0aa0c77a587672c1d3a5dcb9fd"}
{"repo": ".", "date": "2025-05-06", "line": "feat: Implemented file paste and drag-and-drop support", "commit": "b0666a00900e1b25633433b80da1ef3dd5f2ee71"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Dispatch upload event and focus input after upload", "commit": "707788583a2387c1729950e08243d3f8f7049d7c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added focus functionality with Escape and G keybindings", "commit": "d6d2f2892ba3045e5555e9fb4b3d63adf51e2fc2"}
{"repo": ".", "date": "2025-05-08", "line": "fix: Prevent double focus loss during upload escape key press", "commit": "fa59dbc095b65c7b43a1b7f0e70541bd1fd0302c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "e153811ff34ef63892cc6aac1d5afd92cb510d14"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after message display", "commit": "0a3e15137761d333211d8b52d178f5e150a579f1"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus upload view when shift+G is pressed", "commit": "f6706c165e2a8ba392bde1e81d8006381fed96d3"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after layout update during shift+G", "commit": "8799662159656867494b2774073b3bfb1bbe5178"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Focus input field after upload", "commit": "49ec99ef016dc754e36442d774da1d3a712bf2a7"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Updated Dockerfiles and terminal configuration\n\nThis commit updates the Dockerfiles and terminal configuration with several improvements:\n\n-   Added `xterm`, `valgrind`, `ack`, `irssi`, and `lynx` to the Ubuntu Dockerfile.\n-   Added Rust toolchain installation to the Ubuntu Dockerfile.\n-   Modified the `r` executable placement in the Ubuntu Dockerfile.\n-   Updated the terminal.html template to include fit addon and clear the screen on connection.\n-   Added `$HOME/bin` to the PATH in the .bashrc file.\n-   Removed the `.rcontext.txt` file.\n-   Added `resize > /dev/null` to the .bashrc file.", "commit": "3c1d5d601fa1a9f30b2aa4fd36086102108dde94"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Added ubuntu docker build target and simplified run command", "commit": "d0dd342e27cf4160f96faf87deff81f728e41e47"}
{"repo": ".", "date": "2025-05-08", "line": "chore: Updated Makefile and .bashrc", "commit": "31062fddbfbbf25f206060b38773a7e2c008723c"}
{"repo": ".", "date": "2025-05-08", "line": "feat: Install tmux", "commit": "3c0fea6812a5f5d759c08e6de984c0ccf5f9b9a9"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Implement user template loading based on admin status", "commit": "02a0253c1d9c73d2918fecb8f52c4c7739c867f5"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Prevent appending None to template paths", "commit": "165dda32100e52c347c7f6bb71062244b2a50ba1"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with user-specific paths", "commit": "ac570d036c26b6c7ff5abac169fdf622c10827ad"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Pass self to static_handler", "commit": "b867b6ba78574a332bf951eb6c00a6a88ded325d"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use request.session instead of self.request.session", "commit": "e359a8ebe294e0f55cf4164926011e893468e4bc"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Use user static path for templates", "commit": "5b28044d9e604b2404bf4b7277a240f7fc56032c"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Refactor static file serving with `add_static`", "commit": "c56bf4fb49c986e9b653f635a81937a3ef433a5e"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added repository management functionality\n\nThis commit introduces repository management features, including creation, updating, and deletion. It also includes UI updates to the settings sidebar and templates.\n", "commit": "ee40c905d4448f0b39d28c4d51343b5fe111d038"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added basic Git repository management functionality", "commit": "a5aac9a33701e3d4852fba13520771e6de82aac0"}
{"repo": ".", "date": "2025-05-09", "line": "fix: Corrected repository deletion URL and added repository deletion functionality", "commit": "e06776d81d0fa40e4d9d5f57a6259df8db271372"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Add uvloop dependency and fix repo path resolution", "commit": "adb59eff68e5c855fbce6f930db1ea13f59683f6"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "3ae30f1f7645203a8e8c15bd298d802fffbd2334"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Increase client max size for uploads", "commit": "e5d155e1249f9df7c504a95c98171f7e4fe5d5a4"}
{"repo": ".", "date": "2025-05-09", "line": "refactor: Migrate from argparse to click and improve application startup", "commit": "7e8ae1632d19238954ca96657da1d3950ebd413c"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "95ad49df432195cb127f9fe695eac14678422b37"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "17c6124a57a394c63427a0038e598fdb40560f15"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Initial file manager UI and repository view scaffolding", "commit": "4c34d7eda58530eddb2c8b3479627180d6eeb248"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "1616e4edb97284f705400c0598306202a083f60f"}
{"repo": ".", "date": "2025-05-09", "line": "'choices'", "commit": "44ac1d2bfaa32b99d6ee51d65efdc170d846b1f8"}
{"repo": ".", "date": "2025-05-09", "line": "feat: Added DBService and RPCView db methods", "commit": "dd108c20044540c3801ac461c612392bed76ff89"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Added Drive API and HTML views for file management", "commit": "f0591d493955d9c126e7dee6d1a06917c48bbbd2"}
{"repo": ".", "date": "2025-05-10", "line": "feat: Implemented typing indicator and glow effect for active users", "commit": "3412aa0bf0c0bb138c234f88fad55cb69267df79"}
{"repo": ".", "date": "2025-05-10", "line": "'choices'", "commit": "9133b7c3ce6457fa6c218b540828c752b4ba5c72"}
{"repo": ".", "date": "2025-05-10", "line": "fix: Remove debug print statement in SocketService", "commit": "4d7566de9bb3f2c54954fe72d0332caecd133ffa"}
{"repo": ".", "date": "2025-05-10", "line": "refactor: Remove hardcoded repo and user data", "commit": "2c9004418555dfc2a4c826e5c30aa0d59f332df7"}
{"repo": ".", "date": "2025-05-11", "line": "feat: Added SSH server functionality with user-specific home directories and authentication.", "commit": "01846bf23f7883007b99a2e100240bf3b35b30f2"}
{"repo": ".", "date": "2025-05-11", "line": "fix: Updated SSH port to 2242", "commit": "c48b84bf3ab7cff5dee5670e23db3d771e14fc46"}
{"repo": ".", "date": "2025-05-12", "line": "feat: Add image conversion and resizing support in channel attachments", "commit": "f156a153de1b2f89b99cf0490eb18bf27a611fe1"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added image conversion and resizing support for channel attachments", "commit": "ac2f68f93fd66c0d6ab3682525b2d9c94febff4d"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Improve Windows compatibility and database initialization.", "commit": "a4bea9449526fc8f6b01c02d777db5c30186b830"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added WebSocket synchronization service and client.", "commit": "ba3152f553afcfae318811a413cdea6f5be9f413"}
{"repo": ".", "date": "2025-05-13", "line": "refactor: Moved WebSocketClient to system/websocket.py", "commit": "adad5ed4fe37038442d247d9246795a82d31093c"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Remove timing output from task execution", "commit": "2e324ff11815d3c67fffa8e8d5f3e3554f154b57"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Implement image click to view full size", "commit": "d09055986e9a5d971f58075a5e939a268deb26be"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Add width parameter to image URLs", "commit": "8cd2f16c5c46318cb035b882197b516ae5532452"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Added width attribute to image source", "commit": "015b188d5ea16a75d4ae6ef0d9bd6c2514e68fda"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected image width attribute in template rendering", "commit": "12d287042415554c581e7d4fcfc81bd3d733fa02"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image source", "commit": "964a747f42ade75dcfced5395f46727f0508172c"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add width parameter to image sources", "commit": "319c1b1b5264933a7ea1d7af6541be2a410a3328"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected webp image type in template", "commit": "a21e3590ef4ddad292fb914cb3454d07eb622413"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Reduce image width and fix image URL in web template", "commit": "b55d74fb124b90ee24d158bc94c401b0ff19edb9"}
{"repo": ".", "date": "2025-05-13", "line": "style: Added cursor pointer to chat message images", "commit": "3858dcbd62e4032a02e9d25dffd000ade4dc7bbe"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Add height parameter to image rendering", "commit": "0ea0cd96dbe536b09cb3549de47766a756f04008"}
{"repo": ".", "date": "2025-05-13", "line": "feat: Encode attachment URLs for safe transmission", "commit": "af1cf4f5aee9cc07c1c8a8e8039c19001e3d6ea9"}
{"repo": ".", "date": "2025-05-13", "line": "fix: Corrected attachment URL and added scroll to drive view", "commit": "c45b61681dc2fc2a239e1bc2672da44f7738b0c6"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Introduce online user list and typing indicator", "commit": "db6d6c0106267f56822ae378a4c88385d025051a"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Updated message text updating and added message age check.", "commit": "25d109beedf030523ac5d357dbbb1f9efb919edb"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Implemented dialogs for online users and help, removed online.html", "commit": "dd80f3732b7f500acdd92f6e44f42f9ade0f205b"}
{"repo": ".", "date": "2025-05-15", "line": "feat: Added /live command to help dialog", "commit": "79c39828f0a3282f53e3322e19b211b5559466a1"}
{"repo": ".", "date": "2025-05-16", "line": "'choices'", "commit": "c5b55399a1fbea233b33a9e5fdde1fe2cd9167aa"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Update socket service and attachment view\n\n- Added last_ping to user data on socket connection.\n- Fixed bug in attachment view where format was not correctly passed to image.save.\n- Improved attachment view to handle multiple attachments.", "commit": "93462d4c4b93c4f7eb81702801df9159cbb64e8e"}
{"repo": ".", "date": "2025-05-16", "line": "fix: Increased last ping cooldown to 180 seconds", "commit": "c387225a6e8aa826b944ff3c53c4045db25db758"}
{"repo": ".", "date": "2025-05-16", "line": "feat: Added cache enable/disable functionality", "commit": "00557ec9eaab7256d5c129fc8c00c12650ea3fd3"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor index.html with improved styling and content", "commit": "c0b4ba715c329273e4f5684d1ec2e231e5a1c7e7"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Refactor chat input component with improved auto-completion and live typing functionality", "commit": "48c3daf3983e3b6e04a0c5888febceb69db9d661"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Improved YouTube embed parsing and added start time support\n\nfix: Handle missing channel attachments and return 404", "commit": "ffb22165da408d653dec3baeed2a5aca972eaea6"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Add webp format support to image rendering", "commit": "1bed47fbf59f3fb9bfeeae955272f6b2442d92b3"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Re-enable webp fallback with png fallback", "commit": "53811ca9b2259cd24dcada84816c3e622b801835"}
{"repo": ".", "date": "2025-05-17", "line": "feat: Added star animation to sandbox page", "commit": "e79abf4a26454cddf766cd1ba138554817c820cb"}
{"repo": ".", "date": "2025-05-18", "line": "Merge youtube embed fix", "commit": "59a2668c8c63c0292623cf410a3806666940f49b"}
{"repo": ".", "date": "2025-05-18", "line": "fix: Improved GIF resizing quality", "commit": "0f337e569fba59018d41628e9e471cf0c088113e"}
