Compare commits
59 Commits
51a163bd73
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c8bc0eb1a | |||
| 72809d024e | |||
|
|
59b928d4f7 | ||
|
|
12902d126c | ||
|
|
c096e239e7 | ||
|
|
abbe00d70f | ||
|
|
e3ea5f8051 | ||
|
|
c656956365 | ||
|
|
bab99443b6 | ||
|
|
5959e6e35a | ||
|
|
6a4b8d9199 | ||
|
|
473205007f | ||
|
|
e99b7584d2 | ||
|
|
294bd887ba | ||
|
|
8eb1afb91c | ||
|
|
160670bea3 | ||
|
|
022df56c9e | ||
|
|
7d6fba5ac7 | ||
|
|
4de8e87f06 | ||
| f2de3d9584 | |||
| a9eecd1c05 | |||
|
|
8521182a09 | ||
|
|
456525aa25 | ||
|
|
0e6e55a920 | ||
|
|
bd1bc59859 | ||
| d00a991b84 | |||
| 4ec8ab50e7 | |||
| b248771619 | |||
| 85e78bae0e | |||
| 442a1aafee | |||
| c7287c861c | |||
|
|
6e6b54c4c8 | ||
|
|
09b31c881e | ||
|
|
de4bd2c4a6 | ||
|
|
1e61d1c538 | ||
|
|
fb4501e9b3 | ||
|
|
9302731734 | ||
|
|
71db456ced | ||
|
|
8d82431a22 | ||
|
|
fd1fa516cb | ||
|
|
132b45f670 | ||
|
|
e5c136a32f | ||
|
|
ef3ee1cdf6 | ||
|
|
75fc44e34f | ||
|
|
fc23274c94 | ||
|
|
f5691fd8be | ||
|
|
565508b217 | ||
| b6543b09cc | |||
|
|
bc20e47fb1 | ||
|
|
bc526ab561 | ||
|
|
b61d1e7a3e | ||
|
|
bc0eca1d16 | ||
|
|
8d430953f2 | ||
|
|
45d98fe0b5 | ||
|
|
4e8311fe56 | ||
|
|
2af33fce77 | ||
|
|
111462d3d0 | ||
|
|
9da389ba58 | ||
|
|
7b9b48b1ae |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@ __pycache__
|
||||
.mypy_cache
|
||||
*.egg-info
|
||||
*.pyc
|
||||
venv
|
||||
sitecustomize.py
|
||||
|
||||
*.qmlc
|
||||
*.jsc
|
||||
|
||||
79
PKGBUILD
Normal file
79
PKGBUILD
Normal file
@@ -0,0 +1,79 @@
|
||||
# Maintainer: DrRac27 <drrac27 at riseup.net>
|
||||
|
||||
pkgname=moment-git
|
||||
_name=moment
|
||||
pkgver=v0.7.3.r32.2af33fce
|
||||
pkgrel=1
|
||||
pkgdesc='A customizable, keyboard-operable Matrix client. Fork of Mirage'
|
||||
arch=('x86_64' 'i686' 'aarch64')
|
||||
url='https://mx-moment.xyz/'
|
||||
license=('LGPL3')
|
||||
depends=(
|
||||
'qt5-base'
|
||||
'qt5-declarative'
|
||||
'qt5-quickcontrols2'
|
||||
'qt5-svg'
|
||||
'qt5-graphicaleffects'
|
||||
'qt5-imageformats'
|
||||
'python'
|
||||
'python-pyotherside'
|
||||
'libolm'
|
||||
'libjpeg-turbo'
|
||||
'zlib'
|
||||
'libtiff'
|
||||
'libwebp'
|
||||
'openjpeg2'
|
||||
'libmediainfo'
|
||||
'python-pillow'
|
||||
'python-pymediainfo'
|
||||
'python-cairosvg'
|
||||
'python-aiofiles'
|
||||
'python-appdirs'
|
||||
'python-filetype'
|
||||
'python-html-sanitizer'
|
||||
'python-lxml'
|
||||
'python-mistune>=2'
|
||||
'python-matrix-nio'
|
||||
'libxss'
|
||||
'python-plyer'
|
||||
'python-sortedcontainers'
|
||||
'python-watchgod'
|
||||
'python-redbaron'
|
||||
'dbus-python'
|
||||
'python-hsluv'
|
||||
'python-pycryptodome'
|
||||
'python-simpleaudio'
|
||||
'python-olm'
|
||||
'python-cachetools'
|
||||
'python-atomicwrites'
|
||||
'python-peewee'
|
||||
)
|
||||
makedepends=('cmake' 'git')
|
||||
provides=('moment')
|
||||
conflicts=('moment')
|
||||
source=('git+https://gitlab.com/mx-moment/moment.git')
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "${srcdir}/${_name}"
|
||||
git submodule update --init --recursive
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
cd "${srcdir}/${_name}"
|
||||
local tag=$(git tag --sort=-v:refname | head -1)
|
||||
local commits_since=$(git rev-list $tag..HEAD --count)
|
||||
echo "$tag.r$commits_since.$(git log --pretty=format:'%h' -n 1)"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${_name}"
|
||||
make clean || true
|
||||
qmake PREFIX=/usr moment.pro
|
||||
make
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${_name}"
|
||||
make INSTALL_ROOT="${pkgdir}" install
|
||||
}
|
||||
@@ -11,6 +11,7 @@ Use `pip3 install --user -U requirements-dev.txt` before running this."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -22,42 +23,47 @@ ROOT = Path(__file__).parent
|
||||
|
||||
|
||||
class Watcher(DefaultWatcher):
|
||||
def accept_change(self, entry: os.DirEntry) -> bool:
|
||||
path = Path(entry.path)
|
||||
def accept_change(self, entry: os.DirEntry) -> bool:
|
||||
path = Path(entry.path)
|
||||
|
||||
for bad in ("src/config", "src/themes"):
|
||||
if path.is_relative_to(ROOT / bad):
|
||||
return False
|
||||
for bad in ("src/config", "src/themes"):
|
||||
if path.is_relative_to(ROOT / bad):
|
||||
return False
|
||||
|
||||
for good in ("src", "submodules"):
|
||||
if path.is_relative_to(ROOT / good):
|
||||
return True
|
||||
for good in ("src", "submodules"):
|
||||
if path.is_relative_to(ROOT / good):
|
||||
return True
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
def should_watch_dir(self, entry: os.DirEntry) -> bool:
|
||||
return super().should_watch_dir(entry) and self.accept_change(entry)
|
||||
def should_watch_dir(self, entry: os.DirEntry) -> bool:
|
||||
return super().should_watch_dir(entry) and self.accept_change(entry)
|
||||
|
||||
def should_watch_file(self, entry: os.DirEntry) -> bool:
|
||||
return super().should_watch_file(entry) and self.accept_change(entry)
|
||||
def should_watch_file(self, entry: os.DirEntry) -> bool:
|
||||
return super().should_watch_file(entry) and self.accept_change(entry)
|
||||
|
||||
|
||||
def cmd(*parts) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(parts, cwd=ROOT, check=True)
|
||||
return subprocess.run(parts, cwd=ROOT, check=True)
|
||||
|
||||
|
||||
def run_app(args=sys.argv[1:]) -> None:
|
||||
print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="")
|
||||
print("\n\x1b[36m", "─" * term_size().columns, "\x1b[0m\n", sep="")
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
cmd("qmake", "moment.pro", "CONFIG+=dev")
|
||||
cmd("make")
|
||||
cmd("./moment", "-name", "dev", *args)
|
||||
if shutil.which("qmake-qt5"):
|
||||
QMAKE_CMD = "qmake-qt5"
|
||||
else:
|
||||
QMAKE_CMD = "qmake"
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
cmd(QMAKE_CMD, "moment.pro", "CONFIG+=dev")
|
||||
cmd("make")
|
||||
cmd("./moment", "-name", "dev", *args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2 and sys.argv[1] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
else:
|
||||
(ROOT / "Makefile").exists() and cmd("make", "clean")
|
||||
run_process(ROOT, run_app, callback=print, watcher_cls=Watcher)
|
||||
if len(sys.argv) > 2 and sys.argv[1] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
else:
|
||||
(ROOT / "Makefile").exists() and cmd("make", "clean")
|
||||
run_process(ROOT, run_app, callback=print, watcher_cls=Watcher)
|
||||
|
||||
@@ -6,6 +6,8 @@ The format is based on
|
||||
and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
- [0.7.5 (2024-03-07)](#075-2024-03-07)
|
||||
- [0.7.4 (2024-01-04)](#074-2024-01-04)
|
||||
- [0.7.3 (2022-01-31)](#073-2022-01-31)
|
||||
- [0.7.2 (2021-07-26)](#072-2021-07-26)
|
||||
- [0.7.1 (2021-03-04)](#071-2021-03-04)
|
||||
@@ -24,6 +26,58 @@ and this project adheres to
|
||||
- [0.4.0 (2020-03-21)](#040-2020-03-21)
|
||||
|
||||
|
||||
## 0.7.5 (2024-03-07)
|
||||
|
||||
### Added
|
||||
|
||||
- Added emoji processing, for example `:eyes:`
|
||||
|
||||
- Added /react command, to add (emoji) reactions
|
||||
|
||||
- Added displaying of spoiler tags
|
||||
|
||||
- Added /spoiler and /unspoiler commands
|
||||
|
||||
### Changed
|
||||
|
||||
- Now using Mistune 3
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed profile picture flickering in account settings UI
|
||||
|
||||
- Fixed overlaying tabs glitch in many UIs
|
||||
|
||||
- Fixed some issues when running on OpenBSD
|
||||
|
||||
## 0.7.4 (2024-01-04)
|
||||
|
||||
### Added
|
||||
|
||||
- Display emoji reactions on messages
|
||||
|
||||
- Display edited messages properly
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed command line argument processing
|
||||
|
||||
- Changed server listing to use servers.joinmatrix.org
|
||||
|
||||
- Now using Mistune 2.0.2
|
||||
|
||||
- Now using upstream matrix-nio (instead of fork)
|
||||
|
||||
### Removed
|
||||
|
||||
- Almost all UI animations were removed
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed restoring from tray
|
||||
|
||||
- Updated installation instructions
|
||||
|
||||
## 0.7.3 (2022-01-31)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -17,6 +17,7 @@ but compiling on Windows and macOS should be possible with the right tools.
|
||||
- [Gentoo / emerge](#gentoo-emerge)
|
||||
- [Ubuntu 19.04 / apt](#ubuntu-1904-apt)
|
||||
- [Ubuntu 19.10+, Debian bullseye / apt](#ubuntu-1910-debian-bullseye-apt)
|
||||
- [Pop! OS](#pop-os)
|
||||
- [Void Linux / xbps](#void-linux-xbps)
|
||||
- [Installing PyOtherSide manually](#installing-pyotherside-manually)
|
||||
- [Installing libolm manually](#installing-libolm-manually)
|
||||
@@ -24,7 +25,8 @@ but compiling on Windows and macOS should be possible with the right tools.
|
||||
- [Common issues](#common-issues)
|
||||
- [cffi version mismatch](#cffi-version-mismatch)
|
||||
- [Type XYZ unavailable](#type-xyz-unavailable)
|
||||
|
||||
- [libimagequant.so.0: cannot open shared object file: No such file or directory](#libimagequantso0-cannot-open-shared-object-file-no-such-file-or-directory)
|
||||
|
||||
|
||||
## Packages
|
||||
|
||||
@@ -166,7 +168,7 @@ export PATH="/usr/lib/qt5/bin:$PATH"
|
||||
#### Arch Linux / pacman
|
||||
|
||||
```sh
|
||||
pacman -Syu qt5-base qt5-declarative qt5-quickcontrols2 qt5-svg \
|
||||
pacman -S qt5-base qt5-declarative qt5-quickcontrols2 qt5-svg \
|
||||
qt5-graphicaleffects qt5-imageformats \
|
||||
libx11 libxss alsa-lib \
|
||||
python python-pip \
|
||||
@@ -256,9 +258,27 @@ sudo apt install qt5-default qt5-qmake qt5-image-formats-plugins \
|
||||
libolm-dev
|
||||
```
|
||||
|
||||
#### Void Linux / xbps
|
||||
#### Pop! OS
|
||||
|
||||
[PyOtherSide](#installing-pyotherside-manually) must be manually installed.
|
||||
No need to install libolm manually.
|
||||
|
||||
```sh
|
||||
sudo apt update
|
||||
sudo apt install qt5-qmake qt5-image-formats-plugins qml-module-qtquick2 \
|
||||
qml-module-qtquick-window2 qml-module-qtquick-layouts \
|
||||
qml-module-qtquick-dialogs qml-module-qt-labs-platform \
|
||||
qml-module-qtquick-shapes qml-module-qt-labs-qmlmodels \
|
||||
qml-module-qtgraphicaleffects qml-module-qtquick-controls2 \
|
||||
qtdeclarative5-dev qtquickcontrols2-5-dev libx11-dev \
|
||||
libxss-dev libasound2-dev python3-dev python3-pip \
|
||||
qml-module-io-thp-pyotherside build-essential git \
|
||||
cmake zlib1g-dev libtiff5-dev libwebp-dev libopenjp2-7-dev \
|
||||
libmediainfo-dev libolm-dev meson libdbus-glib-1-dev \
|
||||
libgirepository1.0-dev patchelf
|
||||
pip3 install --user dbus-python
|
||||
```
|
||||
|
||||
#### Void Linux / xbps
|
||||
|
||||
```sh
|
||||
sudo xbps-install -Su qt5-devel qt5-declarative-devel \
|
||||
@@ -266,7 +286,7 @@ sudo xbps-install -Su qt5-devel qt5-declarative-devel \
|
||||
qt5-svg-devel qt5-graphicaleffects qt5-imageformats \
|
||||
libx11-devel libXScrnSaver-devel alsa-lib-devel \
|
||||
python3-devel python3-pip \
|
||||
olm-devel \
|
||||
olm-devel pyotherside \
|
||||
base-devel git cmake \
|
||||
libjpeg-turbo-devel zlib-devel tiff-devel libwebp-devel \
|
||||
libopenjpeg2-devel libmediainfo-devel
|
||||
@@ -360,3 +380,12 @@ sudo chmod 755 /usr/lib/qt5/qml/io/thp/pyotherside/*.so
|
||||
|
||||
Note that the Qt lib path might be `/usr/lib/qt/` instead of `/usr/lib/qt5/`,
|
||||
depending on the distro.
|
||||
|
||||
#### libimagequant.so.0: cannot open shared object file: No such file or directory
|
||||
|
||||
Solution from [here](https://stackoverflow.com/questions/77499381/libimagequant-so-0-cannot-open-shared-object-file-no-such-file-or-directory) works.
|
||||
|
||||
```sh
|
||||
sudo ln /lib/libimagequant.so.0.4 /lib/libimagequant.so.0
|
||||
```
|
||||
|
||||
|
||||
@@ -49,4 +49,4 @@ We suggest setting memorable keybindings. In the above example,
|
||||
<kbd>Ctrl+G Ctrl+M</kbd> could mean "Go to Moment". Note that key chords in
|
||||
Moment can be as long as you want - for example, you could have
|
||||
<kbd>Ctrl+G Ctrl+M Ctrl+O</kbd> for `#moment-client:matrix.org` and
|
||||
<kbd>Ctrl+G Ctrl+M Ctrl+I</kbd> for `#mirage-client:matrix.org`.
|
||||
<kbd>Ctrl+G Ctrl+M Ctrl+A</kbd> for `#matrix:matrix.org`.
|
||||
|
||||
@@ -2,31 +2,31 @@ import json
|
||||
import yaml
|
||||
|
||||
with open("moment.flatpak.base.yaml") as f:
|
||||
base = yaml.load(f, Loader=yaml.FullLoader)
|
||||
base = yaml.load(f, Loader=yaml.FullLoader)
|
||||
|
||||
with open("flatpak-pip.json") as f:
|
||||
modules = json.load(f)["modules"]
|
||||
modules = json.load(f)["modules"]
|
||||
|
||||
# set some modules in front as dependencies and dropping matrix-nio
|
||||
# which is declared separately
|
||||
front = []
|
||||
back = []
|
||||
for m in modules:
|
||||
n = m["name"]
|
||||
if n.startswith("python3-") and \
|
||||
n[len("python3-"):] in ["cffi", "importlib-metadata", "multidict", "pytest-runner", "setuptools-scm"]:
|
||||
front.append(m)
|
||||
else:
|
||||
back.append(m)
|
||||
n = m["name"]
|
||||
if n.startswith("python3-") and \
|
||||
n[len("python3-"):] in ["cffi", "importlib-metadata", "multidict", "pytest-runner", "setuptools-scm"]:
|
||||
front.append(m)
|
||||
else:
|
||||
back.append(m)
|
||||
|
||||
# replace placeholder with modules
|
||||
phold = None
|
||||
for i in range(len(base["modules"])):
|
||||
if base["modules"][i]["name"] == "PLACEHOLDER PYTHON DEPENDENCIES":
|
||||
phold = i
|
||||
break
|
||||
if base["modules"][i]["name"] == "PLACEHOLDER PYTHON DEPENDENCIES":
|
||||
phold = i
|
||||
break
|
||||
|
||||
base["modules"] = base["modules"][:i] + front + back + base["modules"][i+1:]
|
||||
|
||||
with open("moment.flatpak.yaml", "w") as f:
|
||||
f.write(yaml.dump(base, sort_keys=False, indent=2))
|
||||
f.write(yaml.dump(base, sort_keys=False, indent=2))
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<provides>
|
||||
<binary>moment</binary>
|
||||
</provides>
|
||||
<requires>
|
||||
<display_length compare="ge">360</display_length>
|
||||
</requires>
|
||||
<project_license>LGPL-3.0-or-later</project_license>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
@@ -56,6 +59,8 @@
|
||||
</screenshots>
|
||||
<metadata_license>FSFAP</metadata_license>
|
||||
<releases>
|
||||
<release version="0.7.5" date="2024-03-07"/>
|
||||
<release version="0.7.4" date="2024-01-04"/>
|
||||
<release version="0.7.3" date="2022-01-31"/>
|
||||
<release version="0.7.2" date="2021-07-26"/>
|
||||
<release version="0.7.1" date="2021-03-04"/>
|
||||
@@ -73,8 +78,4 @@
|
||||
<release version="0.4.1" date="2020-03-23"/>
|
||||
<release version="0.4.0" date="2020-03-21"/>
|
||||
</releases>
|
||||
<custom>
|
||||
<value key="Purism::form_factor">workstation</value>
|
||||
<value key="Purism::form_factor">mobile</value>
|
||||
</custom>
|
||||
</component>
|
||||
|
||||
@@ -4,29 +4,29 @@ import html
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
title_pattern = re.compile(r"## (\d+\.\d+\.\d+) \((\d{4}-\d\d-\d\d)\)")
|
||||
release_lines = [" <releases>"]
|
||||
|
||||
for line in (root / "docs" / "CHANGELOG.md").read_text().splitlines():
|
||||
match = title_pattern.match(line)
|
||||
match = title_pattern.match(line)
|
||||
|
||||
if match:
|
||||
args = (html.escape(match.group(1)), html.escape(match.group(2)))
|
||||
release_lines.append(' <release version="%s" date="%s"/>' % args)
|
||||
if match:
|
||||
args = (html.escape(match.group(1)), html.escape(match.group(2)))
|
||||
release_lines.append(' <release version="%s" date="%s"/>' % args)
|
||||
|
||||
appdata = root / "packaging" / "moment.metainfo.xml"
|
||||
appdata = root / "packaging" / "moment.metainfo.xml"
|
||||
in_releases = False
|
||||
final_lines = []
|
||||
|
||||
for line in appdata.read_text().splitlines():
|
||||
if line == " <releases>":
|
||||
in_releases = True
|
||||
final_lines += release_lines
|
||||
elif line == " </releases>":
|
||||
in_releases = False
|
||||
if line == " <releases>":
|
||||
in_releases = True
|
||||
final_lines += release_lines
|
||||
elif line == " </releases>":
|
||||
in_releases = False
|
||||
|
||||
if not in_releases:
|
||||
final_lines.append(line)
|
||||
if not in_releases:
|
||||
final_lines.append(line)
|
||||
|
||||
appdata.write_text("\n".join(final_lines))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
remote_pdb >= 2.0.0, < 3
|
||||
pdbpp >= 0.10.2, < 0.11
|
||||
devtools >= 0.4.0, < 0.5
|
||||
devtools >= 0.12.0, < 0.13
|
||||
|
||||
mypy >= 0.812, < 0.900
|
||||
flake8 >= 3.8.4, < 4
|
||||
flake8-isort >= 4.0.0, < 5
|
||||
flake8-bugbear >= 20.1.4, < 21
|
||||
flake8-commas >= 2.0.0, < 3
|
||||
flake8-comprehensions >= 3.3.0, < 4
|
||||
flake8-executable >= 2.0.4, < 3
|
||||
flake8-logging-format >= 0.6.0, < 0.7
|
||||
flake8-pie >= 0.6.1, < 0.7
|
||||
flake8-quotes >= 3.2.0, < 4
|
||||
flake8-colors >= 0.1.6, < 0.2
|
||||
mypy >= 1.7.0, < 1.8
|
||||
flake8 >= 6.1.0, < 7
|
||||
flake8-isort >= 6.1.0, < 7
|
||||
flake8-bugbear >= 23.12.0, < 24
|
||||
flake8-commas >= 2.0.0, < 3
|
||||
flake8-comprehensions >= 3.3.0, < 4
|
||||
flake8-executable >= 2.0.4, < 3
|
||||
flake8-logging-format >= 0.9.0, < 1
|
||||
flake8-pie >= 0.16.0, < 1
|
||||
flake8-quotes >= 3.2.0, < 4
|
||||
flake8-colors >= 0.1.6, < 0.2
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
Pillow >= 7.0.0, < 9
|
||||
aiofiles >= 0.4.0, < 0.7
|
||||
aiofiles >= 0.4.0, < 24.0.0
|
||||
appdirs >= 1.4.4, < 2
|
||||
cairosvg >= 2.4.2, < 3
|
||||
emoji >= 2.0, < 3.0
|
||||
filetype >= 1.0.7, < 2
|
||||
html_sanitizer >= 1.9.1, < 2
|
||||
lxml >= 4.5.1, < 5
|
||||
mistune >= 0.8.4, < 0.9
|
||||
pymediainfo >= 4.2.1, < 5
|
||||
html_sanitizer >= 1.9.1, < 3
|
||||
lxml >= 4.5.1, < 6
|
||||
mistune >= 2.0.0, < 4.0
|
||||
pymediainfo >= 4.2.1, < 7
|
||||
plyer >= 1.4.3, < 2
|
||||
sortedcontainers >= 2.2.2, < 3
|
||||
watchgod >= 0.7, < 0.8
|
||||
@@ -14,9 +15,8 @@ redbaron >= 0.9.2, < 1
|
||||
hsluv >= 5.0.0, < 6
|
||||
simpleaudio >= 1.0.4, < 2
|
||||
dbus-python >= 1.2.16, < 2; platform_system == "Linux"
|
||||
matrix-nio[e2e] >= 0.22.0, < 0.24
|
||||
|
||||
async_generator >= 1.10, < 2; python_version < "3.7"
|
||||
dataclasses >= 0.6, < 0.7; python_version < "3.7"
|
||||
pyfastcopy >= 1.0.3, < 2; python_version < "3.8"
|
||||
|
||||
git+https://github.com/MRAAGH/matrix-nio#egg=matrix-nio[e2e]
|
||||
|
||||
@@ -13,7 +13,7 @@ documentation in the following modules first:
|
||||
- `nio_callbacks`
|
||||
"""
|
||||
|
||||
__app_name__ = "moment"
|
||||
__app_name__ = "moment"
|
||||
__display_name__ = "Moment"
|
||||
__reverse_dns__ = "xyz.mx-moment"
|
||||
__version__ = "0.7.3"
|
||||
__version__ = "0.7.5"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,442 +17,442 @@ ColorTuple = Tuple[float, float, float, float]
|
||||
|
||||
@dataclass(repr=False)
|
||||
class Color:
|
||||
"""A color manipulable in HSLuv, HSL, RGB, hexadecimal and by SVG name.
|
||||
"""A color manipulable in HSLuv, HSL, RGB, hexadecimal and by SVG name.
|
||||
|
||||
The `Color` object constructor accepts hexadecimal string
|
||||
("#RGB", "#RRGGBB" or "#RRGGBBAA") or another `Color` to copy.
|
||||
The `Color` object constructor accepts hexadecimal string
|
||||
("#RGB", "#RRGGBB" or "#RRGGBBAA") or another `Color` to copy.
|
||||
|
||||
Attributes representing the color in HSLuv, HSL, RGB, hexadecimal and
|
||||
SVG name formats can be accessed and modified on these `Color` objects.
|
||||
Attributes representing the color in HSLuv, HSL, RGB, hexadecimal and
|
||||
SVG name formats can be accessed and modified on these `Color` objects.
|
||||
|
||||
The `hsluv()`/`hsluva()`, `hsl()`/`hsla()` and `rgb()`/`rgba()`
|
||||
functions in this module are provided to create an object by specifying
|
||||
a color in other formats.
|
||||
The `hsluv()`/`hsluva()`, `hsl()`/`hsla()` and `rgb()`/`rgba()`
|
||||
functions in this module are provided to create an object by specifying
|
||||
a color in other formats.
|
||||
|
||||
Copies of objects with modified attributes can be created with the
|
||||
with the `Color.but()`, `Color.plus()` and `Copy.times()` methods.
|
||||
Copies of objects with modified attributes can be created with the
|
||||
with the `Color.but()`, `Color.plus()` and `Copy.times()` methods.
|
||||
|
||||
If the `hue` is outside of the normal 0-359 range, the number is
|
||||
interpreted as `hue % 360`, e.g. `360` is `0`, `460` is `100`,
|
||||
or `-20` is `340`.
|
||||
"""
|
||||
If the `hue` is outside of the normal 0-359 range, the number is
|
||||
interpreted as `hue % 360`, e.g. `360` is `0`, `460` is `100`,
|
||||
or `-20` is `340`.
|
||||
"""
|
||||
|
||||
# The saturation and luv are properties due to the need for a setter
|
||||
# capping the value between 0-100, as hsluv handles numbers outside
|
||||
# this range incorrectly.
|
||||
# The saturation and luv are properties due to the need for a setter
|
||||
# capping the value between 0-100, as hsluv handles numbers outside
|
||||
# this range incorrectly.
|
||||
|
||||
color_or_hex: InitVar[str] = "#00000000"
|
||||
hue: float = field(init=False, default=0)
|
||||
_saturation: float = field(init=False, default=0)
|
||||
_luv: float = field(init=False, default=0)
|
||||
alpha: float = field(init=False, default=1)
|
||||
color_or_hex: InitVar[str] = "#00000000"
|
||||
hue: float = field(init=False, default=0)
|
||||
_saturation: float = field(init=False, default=0)
|
||||
_luv: float = field(init=False, default=0)
|
||||
alpha: float = field(init=False, default=1)
|
||||
|
||||
def __post_init__(self, color_or_hex: Union["Color", str]) -> None:
|
||||
if isinstance(color_or_hex, Color):
|
||||
hsluva = color_or_hex.hsluva
|
||||
self.hue, self.saturation, self.luv, self.alpha = hsluva
|
||||
else:
|
||||
self.hex = color_or_hex
|
||||
def __post_init__(self, color_or_hex: Union["Color", str]) -> None:
|
||||
if isinstance(color_or_hex, Color):
|
||||
hsluva = color_or_hex.hsluva
|
||||
self.hue, self.saturation, self.luv, self.alpha = hsluva
|
||||
else:
|
||||
self.hex = color_or_hex
|
||||
|
||||
# HSLuv
|
||||
# HSLuv
|
||||
|
||||
@property
|
||||
def hsluva(self) -> ColorTuple:
|
||||
return (self.hue, self.saturation, self.luv, self.alpha)
|
||||
@property
|
||||
def hsluva(self) -> ColorTuple:
|
||||
return (self.hue, self.saturation, self.luv, self.alpha)
|
||||
|
||||
@hsluva.setter
|
||||
def hsluva(self, value: ColorTuple) -> None:
|
||||
self.hue, self.saturation, self.luv, self.alpha = value
|
||||
@hsluva.setter
|
||||
def hsluva(self, value: ColorTuple) -> None:
|
||||
self.hue, self.saturation, self.luv, self.alpha = value
|
||||
|
||||
@property
|
||||
def saturation(self) -> float:
|
||||
return self._saturation
|
||||
@property
|
||||
def saturation(self) -> float:
|
||||
return self._saturation
|
||||
|
||||
@saturation.setter
|
||||
def saturation(self, value: float) -> None:
|
||||
self._saturation = max(0, min(100, value))
|
||||
@saturation.setter
|
||||
def saturation(self, value: float) -> None:
|
||||
self._saturation = max(0, min(100, value))
|
||||
|
||||
@property
|
||||
def luv(self) -> float:
|
||||
return self._luv
|
||||
@property
|
||||
def luv(self) -> float:
|
||||
return self._luv
|
||||
|
||||
@luv.setter
|
||||
def luv(self, value: float) -> None:
|
||||
self._luv = max(0, min(100, value))
|
||||
@luv.setter
|
||||
def luv(self, value: float) -> None:
|
||||
self._luv = max(0, min(100, value))
|
||||
|
||||
# HSL
|
||||
# HSL
|
||||
|
||||
@property
|
||||
def hsla(self) -> ColorTuple:
|
||||
r, g, b = (self.red / 255, self.green / 255, self.blue / 255)
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
return (h * 360, s * 100, l * 100, self.alpha)
|
||||
@property
|
||||
def hsla(self) -> ColorTuple:
|
||||
r, g, b = (self.red / 255, self.green / 255, self.blue / 255)
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
return (h * 360, s * 100, l * 100, self.alpha)
|
||||
|
||||
@hsla.setter
|
||||
def hsla(self, value: ColorTuple) -> None:
|
||||
h, s, l = (value[0] / 360, value[1] / 100, value[2] / 100) # noqa
|
||||
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
||||
self.rgba = (r * 255, g * 255, b * 255, value[3])
|
||||
@hsla.setter
|
||||
def hsla(self, value: ColorTuple) -> None:
|
||||
h, s, l = (value[0] / 360, value[1] / 100, value[2] / 100) # noqa
|
||||
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
||||
self.rgba = (r * 255, g * 255, b * 255, value[3])
|
||||
|
||||
@property
|
||||
def light(self) -> float:
|
||||
return self.hsla[2]
|
||||
@property
|
||||
def light(self) -> float:
|
||||
return self.hsla[2]
|
||||
|
||||
@light.setter
|
||||
def light(self, value: float) -> None:
|
||||
self.hsla = (self.hue, self.saturation, value, self.alpha)
|
||||
@light.setter
|
||||
def light(self, value: float) -> None:
|
||||
self.hsla = (self.hue, self.saturation, value, self.alpha)
|
||||
|
||||
# RGB
|
||||
# RGB
|
||||
|
||||
@property
|
||||
def rgba(self) -> ColorTuple:
|
||||
r, g, b = hsluv_to_rgb(self.hsluva)
|
||||
return r * 255, g * 255, b * 255, self.alpha
|
||||
@property
|
||||
def rgba(self) -> ColorTuple:
|
||||
r, g, b = hsluv_to_rgb(self.hsluva)
|
||||
return r * 255, g * 255, b * 255, self.alpha
|
||||
|
||||
@rgba.setter
|
||||
def rgba(self, value: ColorTuple) -> None:
|
||||
r, g, b = (value[0] / 255, value[1] / 255, value[2] / 255)
|
||||
self.hsluva = rgb_to_hsluv((r, g, b)) + (self.alpha,)
|
||||
@rgba.setter
|
||||
def rgba(self, value: ColorTuple) -> None:
|
||||
r, g, b = (value[0] / 255, value[1] / 255, value[2] / 255)
|
||||
self.hsluva = rgb_to_hsluv((r, g, b)) + (self.alpha,)
|
||||
|
||||
@property
|
||||
def red(self) -> float:
|
||||
return self.rgba[0]
|
||||
@property
|
||||
def red(self) -> float:
|
||||
return self.rgba[0]
|
||||
|
||||
@red.setter
|
||||
def red(self, value: float) -> None:
|
||||
self.rgba = (value, self.green, self.blue, self.alpha)
|
||||
@red.setter
|
||||
def red(self, value: float) -> None:
|
||||
self.rgba = (value, self.green, self.blue, self.alpha)
|
||||
|
||||
@property
|
||||
def green(self) -> float:
|
||||
return self.rgba[1]
|
||||
@property
|
||||
def green(self) -> float:
|
||||
return self.rgba[1]
|
||||
|
||||
@green.setter
|
||||
def green(self, value: float) -> None:
|
||||
self.rgba = (self.red, value, self.blue, self.alpha)
|
||||
@green.setter
|
||||
def green(self, value: float) -> None:
|
||||
self.rgba = (self.red, value, self.blue, self.alpha)
|
||||
|
||||
@property
|
||||
def blue(self) -> float:
|
||||
return self.rgba[2]
|
||||
@property
|
||||
def blue(self) -> float:
|
||||
return self.rgba[2]
|
||||
|
||||
@blue.setter
|
||||
def blue(self, value: float) -> None:
|
||||
self.rgba = (self.red, self.green, value, self.alpha)
|
||||
@blue.setter
|
||||
def blue(self, value: float) -> None:
|
||||
self.rgba = (self.red, self.green, value, self.alpha)
|
||||
|
||||
# Hexadecimal
|
||||
# Hexadecimal
|
||||
|
||||
@property
|
||||
def hex(self) -> str:
|
||||
rgb = hsluv_to_hex(self.hsluva)
|
||||
alpha = builtins.hex(int(self.alpha * 255))[2:]
|
||||
alpha = f"0{alpha}" if len(alpha) == 1 else alpha
|
||||
return f"{alpha if self.alpha < 1 else ''}{rgb}".lower()
|
||||
@property
|
||||
def hex(self) -> str:
|
||||
rgb = hsluv_to_hex(self.hsluva)
|
||||
alpha = builtins.hex(int(self.alpha * 255))[2:]
|
||||
alpha = f"0{alpha}" if len(alpha) == 1 else alpha
|
||||
return f"{alpha if self.alpha < 1 else ''}{rgb}".lower()
|
||||
|
||||
@hex.setter
|
||||
def hex(self, value: str) -> None:
|
||||
if len(value) == 4:
|
||||
template = "#{r}{r}{g}{g}{b}{b}"
|
||||
value = template.format(r=value[1], g=value[2], b=value[3])
|
||||
@hex.setter
|
||||
def hex(self, value: str) -> None:
|
||||
if len(value) == 4:
|
||||
template = "#{r}{r}{g}{g}{b}{b}"
|
||||
value = template.format(r=value[1], g=value[2], b=value[3])
|
||||
|
||||
alpha = int(value[-2:] if len(value) == 9 else "ff", 16) / 255
|
||||
alpha = int(value[-2:] if len(value) == 9 else "ff", 16) / 255
|
||||
|
||||
self.hsluva = hex_to_hsluv(value) + (alpha,)
|
||||
self.hsluva = hex_to_hsluv(value) + (alpha,)
|
||||
|
||||
# name color
|
||||
# name color
|
||||
|
||||
@property
|
||||
def name(self) -> Optional[str]:
|
||||
try:
|
||||
return SVGColor(self.hex).name
|
||||
except ValueError:
|
||||
return None
|
||||
@property
|
||||
def name(self) -> Optional[str]:
|
||||
try:
|
||||
return SVGColor(self.hex).name
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str) -> None:
|
||||
self.hex = SVGColor[value.lower()].value.hex
|
||||
@name.setter
|
||||
def name(self, value: str) -> None:
|
||||
self.hex = SVGColor[value.lower()].value.hex
|
||||
|
||||
# Other methods
|
||||
# Other methods
|
||||
|
||||
def __repr__(self) -> str:
|
||||
r, g, b = int(self.red), int(self.green), int(self.blue)
|
||||
h, s, luv = int(self.hue), int(self.saturation), int(self.luv)
|
||||
l = int(self.light) # noqa
|
||||
a = self.alpha
|
||||
block = f"\x1b[38;2;{r};{g};{b}m█████\x1b[0m"
|
||||
sep = "\x1b[1;33m/\x1b[0m"
|
||||
end = f" {sep} {self.name}" if self.name else ""
|
||||
# Need a terminal with true color support to render the block!
|
||||
return (
|
||||
f"{block} hsluva({h}, {s}, {luv}, {a}) {sep} "
|
||||
f"hsla({h}, {s}, {l}, {a}) {sep} rgba({r}, {g}, {b}, {a}) {sep} "
|
||||
f"{self.hex}{end}"
|
||||
)
|
||||
def __repr__(self) -> str:
|
||||
r, g, b = int(self.red), int(self.green), int(self.blue)
|
||||
h, s, luv = int(self.hue), int(self.saturation), int(self.luv)
|
||||
l = int(self.light) # noqa
|
||||
a = self.alpha
|
||||
block = f"\x1b[38;2;{r};{g};{b}m█████\x1b[0m"
|
||||
sep = "\x1b[1;33m/\x1b[0m"
|
||||
end = f" {sep} {self.name}" if self.name else ""
|
||||
# Need a terminal with true color support to render the block!
|
||||
return (
|
||||
f"{block} hsluva({h}, {s}, {luv}, {a}) {sep} "
|
||||
f"hsla({h}, {s}, {l}, {a}) {sep} rgba({r}, {g}, {b}, {a}) {sep} "
|
||||
f"{self.hex}{end}"
|
||||
)
|
||||
|
||||
def but(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
hsluva: Optional[ColorTuple] = None,
|
||||
hsla: Optional[ColorTuple] = None,
|
||||
rgba: Optional[ColorTuple] = None,
|
||||
hex: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with overriden attributes.
|
||||
def but(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
hsluva: Optional[ColorTuple] = None,
|
||||
hsla: Optional[ColorTuple] = None,
|
||||
rgba: Optional[ColorTuple] = None,
|
||||
hex: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with overriden attributes.
|
||||
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50)
|
||||
>>> second = c.but(hue=20, saturation=100)
|
||||
>>> second.hsluva
|
||||
(20, 50, 100, 1)
|
||||
"""
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50)
|
||||
>>> second = c.but(hue=20, saturation=100)
|
||||
>>> second.hsluva
|
||||
(20, 50, 100, 1)
|
||||
"""
|
||||
|
||||
new = copy(self)
|
||||
new = copy(self)
|
||||
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, value)
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, value)
|
||||
|
||||
return new
|
||||
return new
|
||||
|
||||
def plus(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with values added to attributes.
|
||||
def plus(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with values added to attributes.
|
||||
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50)
|
||||
>>> second = c.plus(hue=10, saturation=-20)
|
||||
>>> second.hsluva
|
||||
(110, 30, 50, 1)
|
||||
"""
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50)
|
||||
>>> second = c.plus(hue=10, saturation=-20)
|
||||
>>> second.hsluva
|
||||
(110, 30, 50, 1)
|
||||
"""
|
||||
|
||||
new = copy(self)
|
||||
new = copy(self)
|
||||
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, getattr(self, arg) + value)
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, getattr(self, arg) + value)
|
||||
|
||||
return new
|
||||
return new
|
||||
|
||||
def times(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with multiplied attributes.
|
||||
def times(
|
||||
self,
|
||||
hue: Optional[float] = None,
|
||||
saturation: Optional[float] = None,
|
||||
luv: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
*,
|
||||
light: Optional[float] = None,
|
||||
red: Optional[float] = None,
|
||||
green: Optional[float] = None,
|
||||
blue: Optional[float] = None,
|
||||
) -> "Color":
|
||||
"""Return a copy of this `Color` with multiplied attributes.
|
||||
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50, 0.8)
|
||||
>>> second = c.times(luv=2, alpha=0.5)
|
||||
>>> second.hsluva
|
||||
(100, 50, 100, 0.4)
|
||||
"""
|
||||
Example:
|
||||
>>> first = Color(100, 50, 50, 0.8)
|
||||
>>> second = c.times(luv=2, alpha=0.5)
|
||||
>>> second.hsluva
|
||||
(100, 50, 100, 0.4)
|
||||
"""
|
||||
|
||||
new = copy(self)
|
||||
new = copy(self)
|
||||
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, getattr(self, arg) * value)
|
||||
for arg, value in locals().items():
|
||||
if arg not in ("new", "self") and value is not None:
|
||||
setattr(new, arg, getattr(self, arg) * value)
|
||||
|
||||
return new
|
||||
return new
|
||||
|
||||
|
||||
class SVGColor(Enum):
|
||||
"""Standard SVG/HTML/CSS colors, with the addition of `transparent`."""
|
||||
"""Standard SVG/HTML/CSS colors, with the addition of `transparent`."""
|
||||
|
||||
aliceblue = Color("#f0f8ff")
|
||||
antiquewhite = Color("#faebd7")
|
||||
aqua = Color("#00ffff")
|
||||
aquamarine = Color("#7fffd4")
|
||||
azure = Color("#f0ffff")
|
||||
beige = Color("#f5f5dc")
|
||||
bisque = Color("#ffe4c4")
|
||||
black = Color("#000000")
|
||||
blanchedalmond = Color("#ffebcd")
|
||||
blue = Color("#0000ff")
|
||||
blueviolet = Color("#8a2be2")
|
||||
brown = Color("#a52a2a")
|
||||
burlywood = Color("#deb887")
|
||||
cadetblue = Color("#5f9ea0")
|
||||
chartreuse = Color("#7fff00")
|
||||
chocolate = Color("#d2691e")
|
||||
coral = Color("#ff7f50")
|
||||
cornflowerblue = Color("#6495ed")
|
||||
cornsilk = Color("#fff8dc")
|
||||
crimson = Color("#dc143c")
|
||||
cyan = Color("#00ffff")
|
||||
darkblue = Color("#00008b")
|
||||
darkcyan = Color("#008b8b")
|
||||
darkgoldenrod = Color("#b8860b")
|
||||
darkgray = Color("#a9a9a9")
|
||||
darkgreen = Color("#006400")
|
||||
darkgrey = Color("#a9a9a9")
|
||||
darkkhaki = Color("#bdb76b")
|
||||
darkmagenta = Color("#8b008b")
|
||||
darkolivegreen = Color("#556b2f")
|
||||
darkorange = Color("#ff8c00")
|
||||
darkorchid = Color("#9932cc")
|
||||
darkred = Color("#8b0000")
|
||||
darksalmon = Color("#e9967a")
|
||||
darkseagreen = Color("#8fbc8f")
|
||||
darkslateblue = Color("#483d8b")
|
||||
darkslategray = Color("#2f4f4f")
|
||||
darkslategrey = Color("#2f4f4f")
|
||||
darkturquoise = Color("#00ced1")
|
||||
darkviolet = Color("#9400d3")
|
||||
deeppink = Color("#ff1493")
|
||||
deepskyblue = Color("#00bfff")
|
||||
dimgray = Color("#696969")
|
||||
dimgrey = Color("#696969")
|
||||
dodgerblue = Color("#1e90ff")
|
||||
firebrick = Color("#b22222")
|
||||
floralwhite = Color("#fffaf0")
|
||||
forestgreen = Color("#228b22")
|
||||
fuchsia = Color("#ff00ff")
|
||||
gainsboro = Color("#dcdcdc")
|
||||
ghostwhite = Color("#f8f8ff")
|
||||
gold = Color("#ffd700")
|
||||
goldenrod = Color("#daa520")
|
||||
gray = Color("#808080")
|
||||
green = Color("#008000")
|
||||
greenyellow = Color("#adff2f")
|
||||
grey = Color("#808080")
|
||||
honeydew = Color("#f0fff0")
|
||||
hotpink = Color("#ff69b4")
|
||||
indianred = Color("#cd5c5c")
|
||||
indigo = Color("#4b0082")
|
||||
ivory = Color("#fffff0")
|
||||
khaki = Color("#f0e68c")
|
||||
lavender = Color("#e6e6fa")
|
||||
lavenderblush = Color("#fff0f5")
|
||||
lawngreen = Color("#7cfc00")
|
||||
lemonchiffon = Color("#fffacd")
|
||||
lightblue = Color("#add8e6")
|
||||
lightcoral = Color("#f08080")
|
||||
lightcyan = Color("#e0ffff")
|
||||
lightgoldenrodyellow = Color("#fafad2")
|
||||
lightgray = Color("#d3d3d3")
|
||||
lightgreen = Color("#90ee90")
|
||||
lightgrey = Color("#d3d3d3")
|
||||
lightpink = Color("#ffb6c1")
|
||||
lightsalmon = Color("#ffa07a")
|
||||
lightseagreen = Color("#20b2aa")
|
||||
lightskyblue = Color("#87cefa")
|
||||
lightslategray = Color("#778899")
|
||||
lightslategrey = Color("#778899")
|
||||
lightsteelblue = Color("#b0c4de")
|
||||
lightyellow = Color("#ffffe0")
|
||||
lime = Color("#00ff00")
|
||||
limegreen = Color("#32cd32")
|
||||
linen = Color("#faf0e6")
|
||||
magenta = Color("#ff00ff")
|
||||
maroon = Color("#800000")
|
||||
mediumaquamarine = Color("#66cdaa")
|
||||
mediumblue = Color("#0000cd")
|
||||
mediumorchid = Color("#ba55d3")
|
||||
mediumpurple = Color("#9370db")
|
||||
mediumseagreen = Color("#3cb371")
|
||||
mediumslateblue = Color("#7b68ee")
|
||||
mediumspringgreen = Color("#00fa9a")
|
||||
mediumturquoise = Color("#48d1cc")
|
||||
mediumvioletred = Color("#c71585")
|
||||
midnightblue = Color("#191970")
|
||||
mintcream = Color("#f5fffa")
|
||||
mistyrose = Color("#ffe4e1")
|
||||
moccasin = Color("#ffe4b5")
|
||||
navajowhite = Color("#ffdead")
|
||||
navy = Color("#000080")
|
||||
oldlace = Color("#fdf5e6")
|
||||
olive = Color("#808000")
|
||||
olivedrab = Color("#6b8e23")
|
||||
orange = Color("#ffa500")
|
||||
orangered = Color("#ff4500")
|
||||
orchid = Color("#da70d6")
|
||||
palegoldenrod = Color("#eee8aa")
|
||||
palegreen = Color("#98fb98")
|
||||
paleturquoise = Color("#afeeee")
|
||||
palevioletred = Color("#db7093")
|
||||
papayawhip = Color("#ffefd5")
|
||||
peachpuff = Color("#ffdab9")
|
||||
peru = Color("#cd853f")
|
||||
pink = Color("#ffc0cb")
|
||||
plum = Color("#dda0dd")
|
||||
powderblue = Color("#b0e0e6")
|
||||
purple = Color("#800080")
|
||||
rebeccapurple = Color("#663399")
|
||||
red = Color("#ff0000")
|
||||
rosybrown = Color("#bc8f8f")
|
||||
royalblue = Color("#4169e1")
|
||||
saddlebrown = Color("#8b4513")
|
||||
salmon = Color("#fa8072")
|
||||
sandybrown = Color("#f4a460")
|
||||
seagreen = Color("#2e8b57")
|
||||
seashell = Color("#fff5ee")
|
||||
sienna = Color("#a0522d")
|
||||
silver = Color("#c0c0c0")
|
||||
skyblue = Color("#87ceeb")
|
||||
slateblue = Color("#6a5acd")
|
||||
slategray = Color("#708090")
|
||||
slategrey = Color("#708090")
|
||||
snow = Color("#fffafa")
|
||||
springgreen = Color("#00ff7f")
|
||||
steelblue = Color("#4682b4")
|
||||
tan = Color("#d2b48c")
|
||||
teal = Color("#008080")
|
||||
thistle = Color("#d8bfd8")
|
||||
tomato = Color("#ff6347")
|
||||
transparent = Color("#00000000") # not standard but exists in QML
|
||||
turquoise = Color("#40e0d0")
|
||||
violet = Color("#ee82ee")
|
||||
wheat = Color("#f5deb3")
|
||||
white = Color("#ffffff")
|
||||
whitesmoke = Color("#f5f5f5")
|
||||
yellow = Color("#ffff00")
|
||||
yellowgreen = Color("#9acd32")
|
||||
aliceblue = Color("#f0f8ff")
|
||||
antiquewhite = Color("#faebd7")
|
||||
aqua = Color("#00ffff")
|
||||
aquamarine = Color("#7fffd4")
|
||||
azure = Color("#f0ffff")
|
||||
beige = Color("#f5f5dc")
|
||||
bisque = Color("#ffe4c4")
|
||||
black = Color("#000000")
|
||||
blanchedalmond = Color("#ffebcd")
|
||||
blue = Color("#0000ff")
|
||||
blueviolet = Color("#8a2be2")
|
||||
brown = Color("#a52a2a")
|
||||
burlywood = Color("#deb887")
|
||||
cadetblue = Color("#5f9ea0")
|
||||
chartreuse = Color("#7fff00")
|
||||
chocolate = Color("#d2691e")
|
||||
coral = Color("#ff7f50")
|
||||
cornflowerblue = Color("#6495ed")
|
||||
cornsilk = Color("#fff8dc")
|
||||
crimson = Color("#dc143c")
|
||||
cyan = Color("#00ffff")
|
||||
darkblue = Color("#00008b")
|
||||
darkcyan = Color("#008b8b")
|
||||
darkgoldenrod = Color("#b8860b")
|
||||
darkgray = Color("#a9a9a9")
|
||||
darkgreen = Color("#006400")
|
||||
darkgrey = Color("#a9a9a9")
|
||||
darkkhaki = Color("#bdb76b")
|
||||
darkmagenta = Color("#8b008b")
|
||||
darkolivegreen = Color("#556b2f")
|
||||
darkorange = Color("#ff8c00")
|
||||
darkorchid = Color("#9932cc")
|
||||
darkred = Color("#8b0000")
|
||||
darksalmon = Color("#e9967a")
|
||||
darkseagreen = Color("#8fbc8f")
|
||||
darkslateblue = Color("#483d8b")
|
||||
darkslategray = Color("#2f4f4f")
|
||||
darkslategrey = Color("#2f4f4f")
|
||||
darkturquoise = Color("#00ced1")
|
||||
darkviolet = Color("#9400d3")
|
||||
deeppink = Color("#ff1493")
|
||||
deepskyblue = Color("#00bfff")
|
||||
dimgray = Color("#696969")
|
||||
dimgrey = Color("#696969")
|
||||
dodgerblue = Color("#1e90ff")
|
||||
firebrick = Color("#b22222")
|
||||
floralwhite = Color("#fffaf0")
|
||||
forestgreen = Color("#228b22")
|
||||
fuchsia = Color("#ff00ff")
|
||||
gainsboro = Color("#dcdcdc")
|
||||
ghostwhite = Color("#f8f8ff")
|
||||
gold = Color("#ffd700")
|
||||
goldenrod = Color("#daa520")
|
||||
gray = Color("#808080")
|
||||
green = Color("#008000")
|
||||
greenyellow = Color("#adff2f")
|
||||
grey = Color("#808080")
|
||||
honeydew = Color("#f0fff0")
|
||||
hotpink = Color("#ff69b4")
|
||||
indianred = Color("#cd5c5c")
|
||||
indigo = Color("#4b0082")
|
||||
ivory = Color("#fffff0")
|
||||
khaki = Color("#f0e68c")
|
||||
lavender = Color("#e6e6fa")
|
||||
lavenderblush = Color("#fff0f5")
|
||||
lawngreen = Color("#7cfc00")
|
||||
lemonchiffon = Color("#fffacd")
|
||||
lightblue = Color("#add8e6")
|
||||
lightcoral = Color("#f08080")
|
||||
lightcyan = Color("#e0ffff")
|
||||
lightgoldenrodyellow = Color("#fafad2")
|
||||
lightgray = Color("#d3d3d3")
|
||||
lightgreen = Color("#90ee90")
|
||||
lightgrey = Color("#d3d3d3")
|
||||
lightpink = Color("#ffb6c1")
|
||||
lightsalmon = Color("#ffa07a")
|
||||
lightseagreen = Color("#20b2aa")
|
||||
lightskyblue = Color("#87cefa")
|
||||
lightslategray = Color("#778899")
|
||||
lightslategrey = Color("#778899")
|
||||
lightsteelblue = Color("#b0c4de")
|
||||
lightyellow = Color("#ffffe0")
|
||||
lime = Color("#00ff00")
|
||||
limegreen = Color("#32cd32")
|
||||
linen = Color("#faf0e6")
|
||||
magenta = Color("#ff00ff")
|
||||
maroon = Color("#800000")
|
||||
mediumaquamarine = Color("#66cdaa")
|
||||
mediumblue = Color("#0000cd")
|
||||
mediumorchid = Color("#ba55d3")
|
||||
mediumpurple = Color("#9370db")
|
||||
mediumseagreen = Color("#3cb371")
|
||||
mediumslateblue = Color("#7b68ee")
|
||||
mediumspringgreen = Color("#00fa9a")
|
||||
mediumturquoise = Color("#48d1cc")
|
||||
mediumvioletred = Color("#c71585")
|
||||
midnightblue = Color("#191970")
|
||||
mintcream = Color("#f5fffa")
|
||||
mistyrose = Color("#ffe4e1")
|
||||
moccasin = Color("#ffe4b5")
|
||||
navajowhite = Color("#ffdead")
|
||||
navy = Color("#000080")
|
||||
oldlace = Color("#fdf5e6")
|
||||
olive = Color("#808000")
|
||||
olivedrab = Color("#6b8e23")
|
||||
orange = Color("#ffa500")
|
||||
orangered = Color("#ff4500")
|
||||
orchid = Color("#da70d6")
|
||||
palegoldenrod = Color("#eee8aa")
|
||||
palegreen = Color("#98fb98")
|
||||
paleturquoise = Color("#afeeee")
|
||||
palevioletred = Color("#db7093")
|
||||
papayawhip = Color("#ffefd5")
|
||||
peachpuff = Color("#ffdab9")
|
||||
peru = Color("#cd853f")
|
||||
pink = Color("#ffc0cb")
|
||||
plum = Color("#dda0dd")
|
||||
powderblue = Color("#b0e0e6")
|
||||
purple = Color("#800080")
|
||||
rebeccapurple = Color("#663399")
|
||||
red = Color("#ff0000")
|
||||
rosybrown = Color("#bc8f8f")
|
||||
royalblue = Color("#4169e1")
|
||||
saddlebrown = Color("#8b4513")
|
||||
salmon = Color("#fa8072")
|
||||
sandybrown = Color("#f4a460")
|
||||
seagreen = Color("#2e8b57")
|
||||
seashell = Color("#fff5ee")
|
||||
sienna = Color("#a0522d")
|
||||
silver = Color("#c0c0c0")
|
||||
skyblue = Color("#87ceeb")
|
||||
slateblue = Color("#6a5acd")
|
||||
slategray = Color("#708090")
|
||||
slategrey = Color("#708090")
|
||||
snow = Color("#fffafa")
|
||||
springgreen = Color("#00ff7f")
|
||||
steelblue = Color("#4682b4")
|
||||
tan = Color("#d2b48c")
|
||||
teal = Color("#008080")
|
||||
thistle = Color("#d8bfd8")
|
||||
tomato = Color("#ff6347")
|
||||
transparent = Color("#00000000") # not standard but exists in QML
|
||||
turquoise = Color("#40e0d0")
|
||||
violet = Color("#ee82ee")
|
||||
wheat = Color("#f5deb3")
|
||||
white = Color("#ffffff")
|
||||
whitesmoke = Color("#f5f5f5")
|
||||
yellow = Color("#ffff00")
|
||||
yellowgreen = Color("#9acd32")
|
||||
|
||||
|
||||
def hsluva(
|
||||
hue: float = 0, saturation: float = 0, luv: float = 0, alpha: float = 1,
|
||||
hue: float = 0, saturation: float = 0, luv: float = 0, alpha: float = 1,
|
||||
) -> Color:
|
||||
"""Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSLuv arguments."""
|
||||
return Color().but(hue, saturation, luv, alpha)
|
||||
"""Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSLuv arguments."""
|
||||
return Color().but(hue, saturation, luv, alpha)
|
||||
|
||||
|
||||
def hsla(
|
||||
hue: float = 0, saturation: float = 0, light: float = 0, alpha: float = 1,
|
||||
hue: float = 0, saturation: float = 0, light: float = 0, alpha: float = 1,
|
||||
) -> Color:
|
||||
"""Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSL arguments."""
|
||||
return Color().but(hue, saturation, light=light, alpha=alpha)
|
||||
"""Return a `Color` from `(0-359, 0-100, 0-100, 0-1)` HSL arguments."""
|
||||
return Color().but(hue, saturation, light=light, alpha=alpha)
|
||||
|
||||
|
||||
def rgba(
|
||||
red: float = 0, green: float = 0, blue: float = 0, alpha: float = 1,
|
||||
red: float = 0, green: float = 0, blue: float = 0, alpha: float = 1,
|
||||
) -> Color:
|
||||
"""Return a `Color` from `(0-255, 0-255, 0-255, 0-1)` RGB arguments."""
|
||||
return Color().but(red=red, green=green, blue=blue, alpha=alpha)
|
||||
"""Return a `Color` from `(0-255, 0-255, 0-255, 0-1)` RGB arguments."""
|
||||
return Color().but(red=red, green=green, blue=blue, alpha=alpha)
|
||||
|
||||
|
||||
# Aliases
|
||||
|
||||
@@ -12,117 +12,117 @@ import nio
|
||||
|
||||
@dataclass
|
||||
class MatrixError(Exception):
|
||||
"""An error returned by a Matrix server."""
|
||||
"""An error returned by a Matrix server."""
|
||||
|
||||
http_code: int = 400
|
||||
m_code: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
content: str = ""
|
||||
http_code: int = 400
|
||||
m_code: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
content: str = ""
|
||||
|
||||
@classmethod
|
||||
async def from_nio(cls, response: nio.ErrorResponse) -> "MatrixError":
|
||||
"""Return a `MatrixError` subclass from a nio `ErrorResponse`."""
|
||||
@classmethod
|
||||
async def from_nio(cls, response: nio.ErrorResponse) -> "MatrixError":
|
||||
"""Return a `MatrixError` subclass from a nio `ErrorResponse`."""
|
||||
|
||||
http_code = response.transport_response.status
|
||||
m_code = response.status_code
|
||||
message = response.message
|
||||
content = await response.transport_response.text()
|
||||
http_code = response.transport_response.status
|
||||
m_code = response.status_code
|
||||
message = response.message
|
||||
content = await response.transport_response.text()
|
||||
|
||||
for subcls in cls.__subclasses__():
|
||||
if subcls.m_code and subcls.m_code == m_code:
|
||||
return subcls(http_code, m_code, message, content)
|
||||
for subcls in cls.__subclasses__():
|
||||
if subcls.m_code and subcls.m_code == m_code:
|
||||
return subcls(http_code, m_code, message, content)
|
||||
|
||||
# If error doesn't have a M_CODE, look for a generic http error class
|
||||
for subcls in cls.__subclasses__():
|
||||
if not subcls.m_code and subcls.http_code == http_code:
|
||||
return subcls(http_code, m_code, message, content)
|
||||
# If error doesn't have a M_CODE, look for a generic http error class
|
||||
for subcls in cls.__subclasses__():
|
||||
if not subcls.m_code and subcls.http_code == http_code:
|
||||
return subcls(http_code, m_code, message, content)
|
||||
|
||||
return cls(http_code, m_code, message, content)
|
||||
return cls(http_code, m_code, message, content)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixUnrecognized(MatrixError):
|
||||
http_code: int = 400
|
||||
m_code: str = "M_UNRECOGNIZED"
|
||||
http_code: int = 400
|
||||
m_code: str = "M_UNRECOGNIZED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixInvalidAccessToken(MatrixError):
|
||||
http_code: int = 401
|
||||
m_code: str = "M_UNKNOWN_TOKEN"
|
||||
http_code: int = 401
|
||||
m_code: str = "M_UNKNOWN_TOKEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixUnauthorized(MatrixError):
|
||||
http_code: int = 401
|
||||
m_code: str = "M_UNAUTHORIZED"
|
||||
http_code: int = 401
|
||||
m_code: str = "M_UNAUTHORIZED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixForbidden(MatrixError):
|
||||
http_code: int = 403
|
||||
m_code: str = "M_FORBIDDEN"
|
||||
http_code: int = 403
|
||||
m_code: str = "M_FORBIDDEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixBadJson(MatrixError):
|
||||
http_code: int = 403
|
||||
m_code: str = "M_BAD_JSON"
|
||||
http_code: int = 403
|
||||
m_code: str = "M_BAD_JSON"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixNotJson(MatrixError):
|
||||
http_code: int = 403
|
||||
m_code: str = "M_NOT_JSON"
|
||||
http_code: int = 403
|
||||
m_code: str = "M_NOT_JSON"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixUserDeactivated(MatrixError):
|
||||
http_code: int = 403
|
||||
m_code: str = "M_USER_DEACTIVATED"
|
||||
http_code: int = 403
|
||||
m_code: str = "M_USER_DEACTIVATED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixNotFound(MatrixError):
|
||||
http_code: int = 404
|
||||
m_code: str = "M_NOT_FOUND"
|
||||
http_code: int = 404
|
||||
m_code: str = "M_NOT_FOUND"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixTooLarge(MatrixError):
|
||||
http_code: int = 413
|
||||
m_code: str = "M_TOO_LARGE"
|
||||
http_code: int = 413
|
||||
m_code: str = "M_TOO_LARGE"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatrixBadGateway(MatrixError):
|
||||
http_code: int = 502
|
||||
m_code: Optional[str] = None
|
||||
http_code: int = 502
|
||||
m_code: Optional[str] = None
|
||||
|
||||
|
||||
# Client errors
|
||||
|
||||
@dataclass
|
||||
class InvalidUserId(Exception):
|
||||
user_id: str = field()
|
||||
user_id: str = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvalidUserInContext(Exception):
|
||||
user_id: str = field()
|
||||
user_id: str = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFromOtherServerDisallowed(Exception):
|
||||
user_id: str = field()
|
||||
user_id: str = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UneededThumbnail(Exception):
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class BadMimeType(Exception):
|
||||
wanted: str = field()
|
||||
got: str = field()
|
||||
wanted: str = field()
|
||||
got: str = field()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,354 +24,354 @@ from .models.model import Model
|
||||
from .utils import Size, atomic_write, current_task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .backend import Backend
|
||||
from .backend import Backend
|
||||
|
||||
if sys.version_info < (3, 8):
|
||||
import pyfastcopy # noqa
|
||||
import pyfastcopy # noqa
|
||||
|
||||
CONCURRENT_DOWNLOADS_LIMIT = asyncio.BoundedSemaphore(8)
|
||||
CONCURRENT_DOWNLOADS_LIMIT = asyncio.BoundedSemaphore(8)
|
||||
ACCESS_LOCKS: DefaultDict[str, asyncio.Lock] = DefaultDict(asyncio.Lock)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaCache:
|
||||
"""Matrix downloaded media cache."""
|
||||
"""Matrix downloaded media cache."""
|
||||
|
||||
backend: "Backend" = field()
|
||||
base_dir: Path = field()
|
||||
backend: "Backend" = field()
|
||||
base_dir: Path = field()
|
||||
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.thumbs_dir = self.base_dir / "thumbnails"
|
||||
self.downloads_dir = self.base_dir / "downloads"
|
||||
def __post_init__(self) -> None:
|
||||
self.thumbs_dir = self.base_dir / "thumbnails"
|
||||
self.downloads_dir = self.base_dir / "downloads"
|
||||
|
||||
self.thumbs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.thumbs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.downloads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
async def get_media(self, *args) -> Path:
|
||||
"""Return `Media(self, ...).get()`'s result. Intended for QML."""
|
||||
return await Media(self, *args).get()
|
||||
async def get_media(self, *args) -> Path:
|
||||
"""Return `Media(self, ...).get()`'s result. Intended for QML."""
|
||||
return await Media(self, *args).get()
|
||||
|
||||
|
||||
async def get_thumbnail(self, width: float, height: float, *args) -> Path:
|
||||
"""Return `Thumbnail(self, ...).get()`'s result. Intended for QML."""
|
||||
# QML sometimes pass float sizes, which matrix API doesn't like.
|
||||
size = (round(width), round(height))
|
||||
return await Thumbnail(
|
||||
self, *args, wanted_size=size, # type: ignore
|
||||
).get()
|
||||
async def get_thumbnail(self, width: float, height: float, *args) -> Path:
|
||||
"""Return `Thumbnail(self, ...).get()`'s result. Intended for QML."""
|
||||
# QML sometimes pass float sizes, which matrix API doesn't like.
|
||||
size = (round(width), round(height))
|
||||
return await Thumbnail(
|
||||
self, *args, wanted_size=size, # type: ignore
|
||||
).get()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Media:
|
||||
"""A matrix media file that is downloaded or has yet to be.
|
||||
"""A matrix media file that is downloaded or has yet to be.
|
||||
|
||||
If the `room_id` is not set, no `Transfer` model item will be registered
|
||||
while this media is being downloaded.
|
||||
"""
|
||||
If the `room_id` is not set, no `Transfer` model item will be registered
|
||||
while this media is being downloaded.
|
||||
"""
|
||||
|
||||
cache: "MediaCache" = field()
|
||||
client_user_id: str = field()
|
||||
mxc: str = field()
|
||||
title: str = field()
|
||||
room_id: Optional[str] = None
|
||||
filesize: Optional[int] = None
|
||||
crypt_dict: Optional[Dict[str, Any]] = field(default=None, repr=False)
|
||||
cache: "MediaCache" = field()
|
||||
client_user_id: str = field()
|
||||
mxc: str = field()
|
||||
title: str = field()
|
||||
room_id: Optional[str] = None
|
||||
filesize: Optional[int] = None
|
||||
crypt_dict: Optional[Dict[str, Any]] = field(default=None, repr=False)
|
||||
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.mxc = re.sub(r"#auto$", "", self.mxc)
|
||||
def __post_init__(self) -> None:
|
||||
self.mxc = re.sub(r"#auto$", "", self.mxc)
|
||||
|
||||
if not re.match(r"^mxc://.+/.+", self.mxc):
|
||||
raise ValueError(f"Invalid mxc URI: {self.mxc}")
|
||||
if not re.match(r"^mxc://.+/.+", self.mxc):
|
||||
raise ValueError(f"Invalid mxc URI: {self.mxc}")
|
||||
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
"""The path where the file either exists or should be downloaded.
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
"""The path where the file either exists or should be downloaded.
|
||||
|
||||
The returned paths are in this form:
|
||||
```
|
||||
<base download folder>/<homeserver domain>/
|
||||
<file title>_<mxc id>.<file extension>`
|
||||
```
|
||||
e.g. `~/.cache/moment/downloads/matrix.org/foo_Hm24ar11i768b0el.png`.
|
||||
"""
|
||||
The returned paths are in this form:
|
||||
```
|
||||
<base download folder>/<homeserver domain>/
|
||||
<file title>_<mxc id>.<file extension>`
|
||||
```
|
||||
e.g. `~/.cache/moment/downloads/matrix.org/foo_Hm24ar11i768b0el.png`.
|
||||
"""
|
||||
|
||||
parsed = urlparse(self.mxc)
|
||||
mxc_id = parsed.path.lstrip("/")
|
||||
title = Path(self.title)
|
||||
filename = f"{title.stem}_{mxc_id}{title.suffix}"
|
||||
return self.cache.downloads_dir / parsed.netloc / filename
|
||||
parsed = urlparse(self.mxc)
|
||||
mxc_id = parsed.path.lstrip("/")
|
||||
title = Path(self.title)
|
||||
filename = f"{title.stem}_{mxc_id}{title.suffix}"
|
||||
return self.cache.downloads_dir / parsed.netloc / filename
|
||||
|
||||
|
||||
async def get(self) -> Path:
|
||||
"""Return the cached file's path, downloading it first if needed."""
|
||||
async def get(self) -> Path:
|
||||
"""Return the cached file's path, downloading it first if needed."""
|
||||
|
||||
async with ACCESS_LOCKS[self.mxc]:
|
||||
try:
|
||||
return await self.get_local()
|
||||
except FileNotFoundError:
|
||||
return await self.create()
|
||||
async with ACCESS_LOCKS[self.mxc]:
|
||||
try:
|
||||
return await self.get_local()
|
||||
except FileNotFoundError:
|
||||
return await self.create()
|
||||
|
||||
|
||||
async def get_local(self) -> Path:
|
||||
"""Return a cached local existing path for this media or raise."""
|
||||
async def get_local(self) -> Path:
|
||||
"""Return a cached local existing path for this media or raise."""
|
||||
|
||||
if not self.local_path.exists():
|
||||
raise FileNotFoundError()
|
||||
if not self.local_path.exists():
|
||||
raise FileNotFoundError()
|
||||
|
||||
return self.local_path
|
||||
return self.local_path
|
||||
|
||||
|
||||
async def create(self) -> Path:
|
||||
"""Download and cache the media file to disk."""
|
||||
async def create(self) -> Path:
|
||||
"""Download and cache the media file to disk."""
|
||||
|
||||
async with CONCURRENT_DOWNLOADS_LIMIT:
|
||||
data = await self._get_remote_data()
|
||||
async with CONCURRENT_DOWNLOADS_LIMIT:
|
||||
data = await self._get_remote_data()
|
||||
|
||||
self.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async with atomic_write(self.local_path, binary=True) as (file, done):
|
||||
await file.write(data)
|
||||
done()
|
||||
async with atomic_write(self.local_path, binary=True) as (file, done):
|
||||
await file.write(data)
|
||||
done()
|
||||
|
||||
if type(self) is Media:
|
||||
for event in self.cache.backend.mxc_events[self.mxc]:
|
||||
event.media_local_path = self.local_path
|
||||
if type(self) is Media:
|
||||
for event in self.cache.backend.mxc_events[self.mxc]:
|
||||
event.media_local_path = self.local_path
|
||||
|
||||
return self.local_path
|
||||
return self.local_path
|
||||
|
||||
|
||||
async def _get_remote_data(self) -> bytes:
|
||||
"""Return the file's data from the matrix server, decrypt if needed."""
|
||||
async def _get_remote_data(self) -> bytes:
|
||||
"""Return the file's data from the matrix server, decrypt if needed."""
|
||||
|
||||
client = self.cache.backend.clients[self.client_user_id]
|
||||
client = self.cache.backend.clients[self.client_user_id]
|
||||
|
||||
transfer: Optional[Transfer] = None
|
||||
model: Optional[Model] = None
|
||||
transfer: Optional[Transfer] = None
|
||||
model: Optional[Model] = None
|
||||
|
||||
if self.room_id:
|
||||
model = self.cache.backend.models[self.room_id, "transfers"]
|
||||
transfer = Transfer(
|
||||
id = uuid4(),
|
||||
is_upload = False,
|
||||
filepath = self.local_path,
|
||||
total_size = self.filesize or 0,
|
||||
status = TransferStatus.Transfering,
|
||||
)
|
||||
assert model is not None
|
||||
client.transfer_tasks[transfer.id] = current_task() # type: ignore
|
||||
model[str(transfer.id)] = transfer
|
||||
if self.room_id:
|
||||
model = self.cache.backend.models[self.room_id, "transfers"]
|
||||
transfer = Transfer(
|
||||
id = uuid4(),
|
||||
is_upload = False,
|
||||
filepath = self.local_path,
|
||||
total_size = self.filesize or 0,
|
||||
status = TransferStatus.Transfering,
|
||||
)
|
||||
assert model is not None
|
||||
client.transfer_tasks[transfer.id] = current_task() # type: ignore
|
||||
model[str(transfer.id)] = transfer
|
||||
|
||||
try:
|
||||
parsed = urlparse(self.mxc)
|
||||
resp = await client.download(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
)
|
||||
except (nio.TransferCancelledError, asyncio.CancelledError):
|
||||
if transfer and model:
|
||||
del model[str(transfer.id)]
|
||||
del client.transfer_tasks[transfer.id]
|
||||
raise
|
||||
try:
|
||||
parsed = urlparse(self.mxc)
|
||||
resp = await client.download(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
)
|
||||
except (nio.TransferCancelledError, asyncio.CancelledError):
|
||||
if transfer and model:
|
||||
del model[str(transfer.id)]
|
||||
del client.transfer_tasks[transfer.id]
|
||||
raise
|
||||
|
||||
if transfer and model:
|
||||
del model[str(transfer.id)]
|
||||
del client.transfer_tasks[transfer.id]
|
||||
if transfer and model:
|
||||
del model[str(transfer.id)]
|
||||
del client.transfer_tasks[transfer.id]
|
||||
|
||||
return await self._decrypt(resp.body)
|
||||
return await self._decrypt(resp.body)
|
||||
|
||||
|
||||
async def _decrypt(self, data: bytes) -> bytes:
|
||||
"""Decrypt an encrypted file's data."""
|
||||
async def _decrypt(self, data: bytes) -> bytes:
|
||||
"""Decrypt an encrypted file's data."""
|
||||
|
||||
if not self.crypt_dict:
|
||||
return data
|
||||
if not self.crypt_dict:
|
||||
return data
|
||||
|
||||
func = functools.partial(
|
||||
nio.crypto.attachments.decrypt_attachment,
|
||||
data,
|
||||
self.crypt_dict["key"]["k"],
|
||||
self.crypt_dict["hashes"]["sha256"],
|
||||
self.crypt_dict["iv"],
|
||||
)
|
||||
func = functools.partial(
|
||||
nio.crypto.attachments.decrypt_attachment,
|
||||
data,
|
||||
self.crypt_dict["key"]["k"],
|
||||
self.crypt_dict["hashes"]["sha256"],
|
||||
self.crypt_dict["iv"],
|
||||
)
|
||||
|
||||
# Run in a separate thread
|
||||
return await asyncio.get_event_loop().run_in_executor(None, func)
|
||||
# Run in a separate thread
|
||||
return await asyncio.get_event_loop().run_in_executor(None, func)
|
||||
|
||||
|
||||
@classmethod
|
||||
async def from_existing_file(
|
||||
cls,
|
||||
cache: "MediaCache",
|
||||
client_user_id: str,
|
||||
mxc: str,
|
||||
existing: Path,
|
||||
overwrite: bool = False,
|
||||
**kwargs,
|
||||
) -> "Media":
|
||||
"""Copy an existing file to cache and return a `Media` for it."""
|
||||
@classmethod
|
||||
async def from_existing_file(
|
||||
cls,
|
||||
cache: "MediaCache",
|
||||
client_user_id: str,
|
||||
mxc: str,
|
||||
existing: Path,
|
||||
overwrite: bool = False,
|
||||
**kwargs,
|
||||
) -> "Media":
|
||||
"""Copy an existing file to cache and return a `Media` for it."""
|
||||
|
||||
media = cls(
|
||||
cache = cache,
|
||||
client_user_id = client_user_id,
|
||||
mxc = mxc,
|
||||
title = existing.name,
|
||||
filesize = existing.stat().st_size,
|
||||
**kwargs,
|
||||
)
|
||||
media.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
media = cls(
|
||||
cache = cache,
|
||||
client_user_id = client_user_id,
|
||||
mxc = mxc,
|
||||
title = existing.name,
|
||||
filesize = existing.stat().st_size,
|
||||
**kwargs,
|
||||
)
|
||||
media.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not media.local_path.exists() or overwrite:
|
||||
func = functools.partial(shutil.copy, existing, media.local_path)
|
||||
await asyncio.get_event_loop().run_in_executor(None, func)
|
||||
if not media.local_path.exists() or overwrite:
|
||||
func = functools.partial(shutil.copy, existing, media.local_path)
|
||||
await asyncio.get_event_loop().run_in_executor(None, func)
|
||||
|
||||
return media
|
||||
return media
|
||||
|
||||
|
||||
@classmethod
|
||||
async def from_bytes(
|
||||
cls,
|
||||
cache: "MediaCache",
|
||||
client_user_id: str,
|
||||
mxc: str,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
overwrite: bool = False,
|
||||
**kwargs,
|
||||
) -> "Media":
|
||||
"""Create a cached file from bytes data and return a `Media` for it."""
|
||||
@classmethod
|
||||
async def from_bytes(
|
||||
cls,
|
||||
cache: "MediaCache",
|
||||
client_user_id: str,
|
||||
mxc: str,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
overwrite: bool = False,
|
||||
**kwargs,
|
||||
) -> "Media":
|
||||
"""Create a cached file from bytes data and return a `Media` for it."""
|
||||
|
||||
media = cls(
|
||||
cache, client_user_id, mxc, filename, filesize=len(data), **kwargs,
|
||||
)
|
||||
media.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
media = cls(
|
||||
cache, client_user_id, mxc, filename, filesize=len(data), **kwargs,
|
||||
)
|
||||
media.local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not media.local_path.exists() or overwrite:
|
||||
path = media.local_path
|
||||
if not media.local_path.exists() or overwrite:
|
||||
path = media.local_path
|
||||
|
||||
async with atomic_write(path, binary=True) as (file, done):
|
||||
await file.write(data)
|
||||
done()
|
||||
async with atomic_write(path, binary=True) as (file, done):
|
||||
await file.write(data)
|
||||
done()
|
||||
|
||||
return media
|
||||
return media
|
||||
|
||||
|
||||
@dataclass
|
||||
class Thumbnail(Media):
|
||||
"""A matrix media's thumbnail, which is downloaded or has yet to be."""
|
||||
"""A matrix media's thumbnail, which is downloaded or has yet to be."""
|
||||
|
||||
wanted_size: Size = (800, 600)
|
||||
wanted_size: Size = (800, 600)
|
||||
|
||||
server_size: Optional[Size] = field(init=False, repr=False, default=None)
|
||||
server_size: Optional[Size] = field(init=False, repr=False, default=None)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def normalize_size(size: Size) -> Size:
|
||||
"""Return standard `(width, height)` matrix thumbnail dimensions.
|
||||
@staticmethod
|
||||
def normalize_size(size: Size) -> Size:
|
||||
"""Return standard `(width, height)` matrix thumbnail dimensions.
|
||||
|
||||
The Matrix specification defines a few standard thumbnail dimensions
|
||||
for homeservers to store and return: 32x32, 96x96, 320x240, 640x480,
|
||||
and 800x600.
|
||||
The Matrix specification defines a few standard thumbnail dimensions
|
||||
for homeservers to store and return: 32x32, 96x96, 320x240, 640x480,
|
||||
and 800x600.
|
||||
|
||||
This method returns the best matching size for a `size` without
|
||||
upscaling, e.g. passing `(641, 480)` will return `(800, 600)`.
|
||||
"""
|
||||
This method returns the best matching size for a `size` without
|
||||
upscaling, e.g. passing `(641, 480)` will return `(800, 600)`.
|
||||
"""
|
||||
|
||||
if size[0] > 640 or size[1] > 480:
|
||||
return (800, 600)
|
||||
if size[0] > 640 or size[1] > 480:
|
||||
return (800, 600)
|
||||
|
||||
if size[0] > 320 or size[1] > 240:
|
||||
return (640, 480)
|
||||
if size[0] > 320 or size[1] > 240:
|
||||
return (640, 480)
|
||||
|
||||
if size[0] > 96 or size[1] > 96:
|
||||
return (320, 240)
|
||||
if size[0] > 96 or size[1] > 96:
|
||||
return (320, 240)
|
||||
|
||||
if size[0] > 32 or size[1] > 32:
|
||||
return (96, 96)
|
||||
if size[0] > 32 or size[1] > 32:
|
||||
return (96, 96)
|
||||
|
||||
return (32, 32)
|
||||
return (32, 32)
|
||||
|
||||
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
"""The path where the thumbnail either exists or should be downloaded.
|
||||
@property
|
||||
def local_path(self) -> Path:
|
||||
"""The path where the thumbnail either exists or should be downloaded.
|
||||
|
||||
The returned paths are in this form:
|
||||
```
|
||||
<base thumbnail folder>/<homeserver domain>/<standard size>/
|
||||
<file title>_<mxc id>.<file extension>`
|
||||
```
|
||||
e.g.
|
||||
`~/.cache/moment/thumbnails/matrix.org/32x32/foo_Hm24ar11i768b0el.png`.
|
||||
"""
|
||||
The returned paths are in this form:
|
||||
```
|
||||
<base thumbnail folder>/<homeserver domain>/<standard size>/
|
||||
<file title>_<mxc id>.<file extension>`
|
||||
```
|
||||
e.g.
|
||||
`~/.cache/moment/thumbnails/matrix.org/32x32/foo_Hm24ar11i768b0el.png`.
|
||||
"""
|
||||
|
||||
size = self.normalize_size(self.server_size or self.wanted_size)
|
||||
size_dir = f"{size[0]}x{size[1]}"
|
||||
size = self.normalize_size(self.server_size or self.wanted_size)
|
||||
size_dir = f"{size[0]}x{size[1]}"
|
||||
|
||||
parsed = urlparse(self.mxc)
|
||||
mxc_id = parsed.path.lstrip("/")
|
||||
title = Path(self.title)
|
||||
filename = f"{title.stem}_{mxc_id}{title.suffix}"
|
||||
parsed = urlparse(self.mxc)
|
||||
mxc_id = parsed.path.lstrip("/")
|
||||
title = Path(self.title)
|
||||
filename = f"{title.stem}_{mxc_id}{title.suffix}"
|
||||
|
||||
return self.cache.thumbs_dir / parsed.netloc / size_dir / filename
|
||||
return self.cache.thumbs_dir / parsed.netloc / size_dir / filename
|
||||
|
||||
|
||||
async def get_local(self) -> Path:
|
||||
"""Return an existing thumbnail path or raise `FileNotFoundError`.
|
||||
async def get_local(self) -> Path:
|
||||
"""Return an existing thumbnail path or raise `FileNotFoundError`.
|
||||
|
||||
If we have a bigger size thumbnail downloaded than the `wanted_size`
|
||||
for the media, return it instead of asking the server for a
|
||||
smaller thumbnail.
|
||||
"""
|
||||
If we have a bigger size thumbnail downloaded than the `wanted_size`
|
||||
for the media, return it instead of asking the server for a
|
||||
smaller thumbnail.
|
||||
"""
|
||||
|
||||
if self.local_path.exists():
|
||||
return self.local_path
|
||||
if self.local_path.exists():
|
||||
return self.local_path
|
||||
|
||||
try_sizes = ((32, 32), (96, 96), (320, 240), (640, 480), (800, 600))
|
||||
parts = list(self.local_path.parts)
|
||||
size = self.normalize_size(self.server_size or self.wanted_size)
|
||||
try_sizes = ((32, 32), (96, 96), (320, 240), (640, 480), (800, 600))
|
||||
parts = list(self.local_path.parts)
|
||||
size = self.normalize_size(self.server_size or self.wanted_size)
|
||||
|
||||
for width, height in try_sizes:
|
||||
if width < size[0] or height < size[1]:
|
||||
continue
|
||||
for width, height in try_sizes:
|
||||
if width < size[0] or height < size[1]:
|
||||
continue
|
||||
|
||||
parts[-2] = f"{width}x{height}"
|
||||
path = Path("/".join(parts))
|
||||
parts[-2] = f"{width}x{height}"
|
||||
path = Path("/".join(parts))
|
||||
|
||||
if path.exists():
|
||||
return path
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
raise FileNotFoundError()
|
||||
raise FileNotFoundError()
|
||||
|
||||
|
||||
async def _get_remote_data(self) -> bytes:
|
||||
"""Return the (decrypted) media file's content from the server."""
|
||||
async def _get_remote_data(self) -> bytes:
|
||||
"""Return the (decrypted) media file's content from the server."""
|
||||
|
||||
parsed = urlparse(self.mxc)
|
||||
client = self.cache.backend.clients[self.client_user_id]
|
||||
parsed = urlparse(self.mxc)
|
||||
client = self.cache.backend.clients[self.client_user_id]
|
||||
|
||||
if self.crypt_dict:
|
||||
# Matrix makes encrypted thumbs only available through the download
|
||||
# end-point, not the thumbnail one
|
||||
resp = await client.download(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
)
|
||||
else:
|
||||
resp = await client.thumbnail(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
width = self.wanted_size[0],
|
||||
height = self.wanted_size[1],
|
||||
)
|
||||
if self.crypt_dict:
|
||||
# Matrix makes encrypted thumbs only available through the download
|
||||
# end-point, not the thumbnail one
|
||||
resp = await client.download(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
)
|
||||
else:
|
||||
resp = await client.thumbnail(
|
||||
server_name = parsed.netloc,
|
||||
media_id = parsed.path.lstrip("/"),
|
||||
width = self.wanted_size[0],
|
||||
height = self.wanted_size[1],
|
||||
)
|
||||
|
||||
decrypted = await self._decrypt(resp.body)
|
||||
decrypted = await self._decrypt(resp.body)
|
||||
|
||||
with io.BytesIO(decrypted) as img:
|
||||
# The server may return a thumbnail bigger than what we asked for
|
||||
self.server_size = PILImage.open(img).size
|
||||
with io.BytesIO(decrypted) as img:
|
||||
# The server may return a thumbnail bigger than what we asked for
|
||||
self.server_size = PILImage.open(img).size
|
||||
|
||||
return decrypted
|
||||
return decrypted
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
from typing import (
|
||||
TYPE_CHECKING, Any, Callable, Collection, Dict, List, Optional, Tuple,
|
||||
TYPE_CHECKING, Any, Callable, Collection, Dict, List, Optional, Tuple,
|
||||
)
|
||||
|
||||
from . import SyncId
|
||||
@@ -10,185 +10,185 @@ from .model import Model
|
||||
from .proxy import ModelProxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model_item import ModelItem
|
||||
from .model_item import ModelItem
|
||||
|
||||
|
||||
class ModelFilter(ModelProxy):
|
||||
"""Filter data from one or more source models."""
|
||||
"""Filter data from one or more source models."""
|
||||
|
||||
def __init__(self, sync_id: SyncId) -> None:
|
||||
self.filtered_out: Dict[Tuple[Optional[SyncId], str], "ModelItem"] = {}
|
||||
self.items_changed_callbacks: List[Callable[[], None]] = []
|
||||
super().__init__(sync_id)
|
||||
def __init__(self, sync_id: SyncId) -> None:
|
||||
self.filtered_out: Dict[Tuple[Optional[SyncId], str], "ModelItem"] = {}
|
||||
self.items_changed_callbacks: List[Callable[[], None]] = []
|
||||
super().__init__(sync_id)
|
||||
|
||||
|
||||
def accept_item(self, item: "ModelItem") -> bool:
|
||||
"""Return whether an item should be present or filtered out."""
|
||||
return True
|
||||
def accept_item(self, item: "ModelItem") -> bool:
|
||||
"""Return whether an item should be present or filtered out."""
|
||||
return True
|
||||
|
||||
|
||||
def source_item_set(
|
||||
self,
|
||||
source: Model,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
value = self.convert_item(value)
|
||||
def source_item_set(
|
||||
self,
|
||||
source: Model,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
value = self.convert_item(value)
|
||||
|
||||
if self.accept_item(value):
|
||||
self.__setitem__(
|
||||
(source.sync_id, key), value, _changed_fields,
|
||||
)
|
||||
self.filtered_out.pop((source.sync_id, key), None)
|
||||
else:
|
||||
self.filtered_out[source.sync_id, key] = value
|
||||
self.pop((source.sync_id, key), None)
|
||||
if self.accept_item(value):
|
||||
self.__setitem__(
|
||||
(source.sync_id, key), value, _changed_fields,
|
||||
)
|
||||
self.filtered_out.pop((source.sync_id, key), None)
|
||||
else:
|
||||
self.filtered_out[source.sync_id, key] = value
|
||||
self.pop((source.sync_id, key), None)
|
||||
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
|
||||
|
||||
def source_item_deleted(self, source: Model, key) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
try:
|
||||
del self[source.sync_id, key]
|
||||
except KeyError:
|
||||
del self.filtered_out[source.sync_id, key]
|
||||
def source_item_deleted(self, source: Model, key) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
try:
|
||||
del self[source.sync_id, key]
|
||||
except KeyError:
|
||||
del self.filtered_out[source.sync_id, key]
|
||||
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
|
||||
|
||||
def source_cleared(self, source: Model) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
for source_sync_id, key in self.copy():
|
||||
if source_sync_id == source.sync_id:
|
||||
try:
|
||||
del self[source.sync_id, key]
|
||||
except KeyError:
|
||||
del self.filtered_out[source.sync_id, key]
|
||||
def source_cleared(self, source: Model) -> None:
|
||||
with self.write_lock:
|
||||
if self.accept_source(source):
|
||||
for source_sync_id, key in self.copy():
|
||||
if source_sync_id == source.sync_id:
|
||||
try:
|
||||
del self[source.sync_id, key]
|
||||
except KeyError:
|
||||
del self.filtered_out[source.sync_id, key]
|
||||
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
|
||||
|
||||
def refilter(
|
||||
self,
|
||||
only_if: Optional[Callable[["ModelItem"], bool]] = None,
|
||||
) -> None:
|
||||
"""Recheck every item to decide if they should be filtered out."""
|
||||
def refilter(
|
||||
self,
|
||||
only_if: Optional[Callable[["ModelItem"], bool]] = None,
|
||||
) -> None:
|
||||
"""Recheck every item to decide if they should be filtered out."""
|
||||
|
||||
with self.write_lock:
|
||||
take_out = []
|
||||
bring_back = []
|
||||
with self.write_lock:
|
||||
take_out = []
|
||||
bring_back = []
|
||||
|
||||
for key, item in sorted(self.items(), key=lambda kv: kv[1]):
|
||||
if only_if and not only_if(item):
|
||||
continue
|
||||
for key, item in sorted(self.items(), key=lambda kv: kv[1]):
|
||||
if only_if and not only_if(item):
|
||||
continue
|
||||
|
||||
if not self.accept_item(item):
|
||||
take_out.append(key)
|
||||
if not self.accept_item(item):
|
||||
take_out.append(key)
|
||||
|
||||
for key, item in self.filtered_out.items():
|
||||
if only_if and not only_if(item):
|
||||
continue
|
||||
for key, item in self.filtered_out.items():
|
||||
if only_if and not only_if(item):
|
||||
continue
|
||||
|
||||
if self.accept_item(item):
|
||||
bring_back.append(key)
|
||||
if self.accept_item(item):
|
||||
bring_back.append(key)
|
||||
|
||||
with self.batch_remove():
|
||||
for key in take_out:
|
||||
self.filtered_out[key] = self.pop(key)
|
||||
with self.batch_remove():
|
||||
for key in take_out:
|
||||
self.filtered_out[key] = self.pop(key)
|
||||
|
||||
for key in bring_back:
|
||||
self[key] = self.filtered_out.pop(key)
|
||||
for key in bring_back:
|
||||
self[key] = self.filtered_out.pop(key)
|
||||
|
||||
if take_out or bring_back:
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
if take_out or bring_back:
|
||||
for callback in self.items_changed_callbacks:
|
||||
callback()
|
||||
|
||||
|
||||
class FieldStringFilter(ModelFilter):
|
||||
"""Filter source models based on if their fields matches a string.
|
||||
"""Filter source models based on if their fields matches a string.
|
||||
|
||||
This is used for filter fields in QML: the user enters some text and only
|
||||
items with a certain field (typically `display_name`) that starts with the
|
||||
entered text will be shown.
|
||||
This is used for filter fields in QML: the user enters some text and only
|
||||
items with a certain field (typically `display_name`) that starts with the
|
||||
entered text will be shown.
|
||||
|
||||
Matching is done using "smart case": insensitive if the filter text is
|
||||
all lowercase, sensitive otherwise.
|
||||
"""
|
||||
Matching is done using "smart case": insensitive if the filter text is
|
||||
all lowercase, sensitive otherwise.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sync_id: SyncId,
|
||||
fields: Collection[str],
|
||||
no_filter_accept_all_items: bool = True,
|
||||
) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
sync_id: SyncId,
|
||||
fields: Collection[str],
|
||||
no_filter_accept_all_items: bool = True,
|
||||
) -> None:
|
||||
|
||||
self.fields = fields
|
||||
self.no_filter_accept_all_items = no_filter_accept_all_items
|
||||
self._filter: str = ""
|
||||
self.fields = fields
|
||||
self.no_filter_accept_all_items = no_filter_accept_all_items
|
||||
self._filter: str = ""
|
||||
|
||||
|
||||
super().__init__(sync_id)
|
||||
super().__init__(sync_id)
|
||||
|
||||
|
||||
@property
|
||||
def filter(self) -> str:
|
||||
return self._filter
|
||||
@property
|
||||
def filter(self) -> str:
|
||||
return self._filter
|
||||
|
||||
|
||||
@filter.setter
|
||||
def filter(self, value: str) -> None:
|
||||
if value != self._filter:
|
||||
self._filter = value
|
||||
self.refilter()
|
||||
@filter.setter
|
||||
def filter(self, value: str) -> None:
|
||||
if value != self._filter:
|
||||
self._filter = value
|
||||
self.refilter()
|
||||
|
||||
|
||||
def accept_item(self, item: "ModelItem") -> bool:
|
||||
if not self.filter:
|
||||
return self.no_filter_accept_all_items
|
||||
def accept_item(self, item: "ModelItem") -> bool:
|
||||
if not self.filter:
|
||||
return self.no_filter_accept_all_items
|
||||
|
||||
fields = {f: getattr(item, f) for f in self.fields}
|
||||
filtr = self.filter
|
||||
lowercase = filtr.lower()
|
||||
fields = {f: getattr(item, f) for f in self.fields}
|
||||
filtr = self.filter
|
||||
lowercase = filtr.lower()
|
||||
|
||||
if lowercase == filtr:
|
||||
# Consider case only if filter isn't all lowercase
|
||||
filtr = lowercase
|
||||
fields = {name: value.lower() for name, value in fields.items()}
|
||||
if lowercase == filtr:
|
||||
# Consider case only if filter isn't all lowercase
|
||||
filtr = lowercase
|
||||
fields = {name: value.lower() for name, value in fields.items()}
|
||||
|
||||
return self.match(fields, filtr)
|
||||
return self.match(fields, filtr)
|
||||
|
||||
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
for value in fields.values():
|
||||
if value.startswith(filtr):
|
||||
return True
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
for value in fields.values():
|
||||
if value.startswith(filtr):
|
||||
return True
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
class FieldSubstringFilter(FieldStringFilter):
|
||||
"""Fuzzy-like alternative to `FieldStringFilter`.
|
||||
"""Fuzzy-like alternative to `FieldStringFilter`.
|
||||
|
||||
All words in the filter string must fully or partially match words in the
|
||||
item field values, e.g. "red l" can match "red light",
|
||||
"tired legs", "light red" (order of the filter words doesn't matter),
|
||||
but not just "red" or "light" by themselves.
|
||||
"""
|
||||
All words in the filter string must fully or partially match words in the
|
||||
item field values, e.g. "red l" can match "red light",
|
||||
"tired legs", "light red" (order of the filter words doesn't matter),
|
||||
but not just "red" or "light" by themselves.
|
||||
"""
|
||||
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
text = " ".join(fields.values())
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
text = " ".join(fields.values())
|
||||
|
||||
for word in filtr.split():
|
||||
if word and word not in text:
|
||||
return False
|
||||
for word in filtr.split():
|
||||
if word and word not in text:
|
||||
return False
|
||||
|
||||
return True
|
||||
return True
|
||||
|
||||
@@ -14,7 +14,7 @@ import lxml # nosec
|
||||
import nio
|
||||
|
||||
from ..presence import Presence
|
||||
from ..utils import AutoStrEnum, auto, strip_html_tags
|
||||
from ..utils import AutoStrEnum, auto, strip_html_tags, serialize_value_for_qml
|
||||
from .model_item import ModelItem
|
||||
|
||||
OptionalExceptionType = Union[Type[None], Type[Exception]]
|
||||
@@ -23,415 +23,423 @@ ZERO_DATE = datetime.fromtimestamp(0)
|
||||
|
||||
|
||||
class TypeSpecifier(AutoStrEnum):
|
||||
"""Enum providing clarification of purpose for some matrix events."""
|
||||
"""Enum providing clarification of purpose for some matrix events."""
|
||||
|
||||
Unset = auto()
|
||||
ProfileChange = auto()
|
||||
MembershipChange = auto()
|
||||
Unset = auto()
|
||||
ProfileChange = auto()
|
||||
MembershipChange = auto()
|
||||
Reaction = auto()
|
||||
ReactionRedaction = auto()
|
||||
MessageReplace = auto()
|
||||
|
||||
|
||||
class PingStatus(AutoStrEnum):
|
||||
"""Enum for the status of a homeserver ping operation."""
|
||||
"""Enum for the status of a homeserver ping operation."""
|
||||
|
||||
Done = auto()
|
||||
Pinging = auto()
|
||||
Failed = auto()
|
||||
Done = auto()
|
||||
Pinging = auto()
|
||||
Failed = auto()
|
||||
|
||||
|
||||
class RoomNotificationOverride(AutoStrEnum):
|
||||
"""Possible per-room notification override settings, as displayed in the
|
||||
left sidepane's context menu when right-clicking a room.
|
||||
"""
|
||||
UseDefaultSettings = auto()
|
||||
AllEvents = auto()
|
||||
HighlightsOnly = auto()
|
||||
IgnoreEvents = auto()
|
||||
"""Possible per-room notification override settings, as displayed in the
|
||||
left sidepane's context menu when right-clicking a room.
|
||||
"""
|
||||
UseDefaultSettings = auto()
|
||||
AllEvents = auto()
|
||||
HighlightsOnly = auto()
|
||||
IgnoreEvents = auto()
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Homeserver(ModelItem):
|
||||
"""A homeserver we can connect to. The `id` field is the server's URL."""
|
||||
"""A homeserver we can connect to. The `id` field is the server's URL."""
|
||||
|
||||
id: str = field()
|
||||
name: str = field()
|
||||
site_url: str = field()
|
||||
country: str = field()
|
||||
ping: int = -1
|
||||
status: PingStatus = PingStatus.Pinging
|
||||
stability: float = -1
|
||||
downtimes_ms: List[float] = field(default_factory=list)
|
||||
id: str = field()
|
||||
name: str = field()
|
||||
site_url: str = field()
|
||||
country: str = field()
|
||||
ping: int = -1
|
||||
status: PingStatus = PingStatus.Pinging
|
||||
stability: float = -1
|
||||
downtimes_ms: List[float] = field(default_factory=list)
|
||||
|
||||
def __lt__(self, other: "Homeserver") -> bool:
|
||||
return (self.name.lower(), self.id) < (other.name.lower(), other.id)
|
||||
def __lt__(self, other: "Homeserver") -> bool:
|
||||
return (self.name.lower(), self.id) < (other.name.lower(), other.id)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Account(ModelItem):
|
||||
"""A logged in matrix account."""
|
||||
"""A logged in matrix account."""
|
||||
|
||||
id: str = field()
|
||||
order: int = -1
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
max_upload_size: int = 0
|
||||
profile_updated: datetime = ZERO_DATE
|
||||
connecting: bool = False
|
||||
total_unread: int = 0
|
||||
total_highlights: int = 0
|
||||
local_unreads: bool = False
|
||||
ignored_users: Set[str] = field(default_factory=set)
|
||||
id: str = field()
|
||||
order: int = -1
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
max_upload_size: int = 0
|
||||
profile_updated: datetime = ZERO_DATE
|
||||
connecting: bool = False
|
||||
total_unread: int = 0
|
||||
total_highlights: int = 0
|
||||
local_unreads: bool = False
|
||||
ignored_users: Set[str] = field(default_factory=set)
|
||||
|
||||
# For some reason, Account cannot inherit Presence, because QML keeps
|
||||
# complaining type error on unknown file
|
||||
presence_support: bool = False
|
||||
save_presence: bool = True
|
||||
presence: Presence.State = Presence.State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = ZERO_DATE
|
||||
status_msg: str = ""
|
||||
# For some reason, Account cannot inherit Presence, because QML keeps
|
||||
# complaining type error on unknown file
|
||||
presence_support: bool = False
|
||||
save_presence: bool = True
|
||||
presence: Presence.State = Presence.State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = ZERO_DATE
|
||||
status_msg: str = ""
|
||||
|
||||
def __lt__(self, other: "Account") -> bool:
|
||||
return (self.order, self.id) < (other.order, other.id)
|
||||
def __lt__(self, other: "Account") -> bool:
|
||||
return (self.order, self.id) < (other.order, other.id)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class PushRule(ModelItem):
|
||||
"""A push rule configured for one of our account."""
|
||||
"""A push rule configured for one of our account."""
|
||||
|
||||
id: Tuple[str, str] = field() # (kind.value, rule_id)
|
||||
kind: nio.PushRuleKind = field()
|
||||
rule_id: str = field()
|
||||
order: int = field()
|
||||
default: bool = field()
|
||||
enabled: bool = True
|
||||
conditions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pattern: str = ""
|
||||
actions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
notify: bool = False
|
||||
highlight: bool = False
|
||||
bubble: bool = False
|
||||
sound: str = "" # usually "default" when set
|
||||
urgency_hint: bool = False
|
||||
id: Tuple[str, str] = field() # (kind.value, rule_id)
|
||||
kind: nio.PushRuleKind = field()
|
||||
rule_id: str = field()
|
||||
order: int = field()
|
||||
default: bool = field()
|
||||
enabled: bool = True
|
||||
conditions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pattern: str = ""
|
||||
actions: List[Dict[str, Any]] = field(default_factory=list)
|
||||
notify: bool = False
|
||||
highlight: bool = False
|
||||
bubble: bool = False
|
||||
sound: str = "" # usually "default" when set
|
||||
urgency_hint: bool = False
|
||||
|
||||
def __lt__(self, other: "PushRule") -> bool:
|
||||
return (
|
||||
self.kind is nio.PushRuleKind.underride,
|
||||
self.kind is nio.PushRuleKind.sender,
|
||||
self.kind is nio.PushRuleKind.room,
|
||||
self.kind is nio.PushRuleKind.content,
|
||||
self.kind is nio.PushRuleKind.override,
|
||||
self.order,
|
||||
self.id,
|
||||
) < (
|
||||
other.kind is nio.PushRuleKind.underride,
|
||||
other.kind is nio.PushRuleKind.sender,
|
||||
other.kind is nio.PushRuleKind.room,
|
||||
other.kind is nio.PushRuleKind.content,
|
||||
other.kind is nio.PushRuleKind.override,
|
||||
other.order,
|
||||
other.id,
|
||||
)
|
||||
def __lt__(self, other: "PushRule") -> bool:
|
||||
return (
|
||||
self.kind is nio.PushRuleKind.underride,
|
||||
self.kind is nio.PushRuleKind.sender,
|
||||
self.kind is nio.PushRuleKind.room,
|
||||
self.kind is nio.PushRuleKind.content,
|
||||
self.kind is nio.PushRuleKind.override,
|
||||
self.order,
|
||||
self.id,
|
||||
) < (
|
||||
other.kind is nio.PushRuleKind.underride,
|
||||
other.kind is nio.PushRuleKind.sender,
|
||||
other.kind is nio.PushRuleKind.room,
|
||||
other.kind is nio.PushRuleKind.content,
|
||||
other.kind is nio.PushRuleKind.override,
|
||||
other.order,
|
||||
other.id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Room(ModelItem):
|
||||
"""A matrix room we are invited to, are or were member of."""
|
||||
"""A matrix room we are invited to, are or were member of."""
|
||||
|
||||
id: str = field()
|
||||
for_account: str = ""
|
||||
given_name: str = ""
|
||||
display_name: str = ""
|
||||
main_alias: str = ""
|
||||
avatar_url: str = ""
|
||||
plain_topic: str = ""
|
||||
topic: str = ""
|
||||
inviter_id: str = ""
|
||||
inviter_name: str = ""
|
||||
inviter_avatar: str = ""
|
||||
left: bool = False
|
||||
id: str = field()
|
||||
for_account: str = ""
|
||||
given_name: str = ""
|
||||
display_name: str = ""
|
||||
main_alias: str = ""
|
||||
avatar_url: str = ""
|
||||
plain_topic: str = ""
|
||||
topic: str = ""
|
||||
inviter_id: str = ""
|
||||
inviter_name: str = ""
|
||||
inviter_avatar: str = ""
|
||||
left: bool = False
|
||||
|
||||
typing_members: List[str] = field(default_factory=list)
|
||||
typing_members: List[str] = field(default_factory=list)
|
||||
|
||||
federated: bool = True
|
||||
encrypted: bool = False
|
||||
unverified_devices: bool = False
|
||||
invite_required: bool = True
|
||||
guests_allowed: bool = True
|
||||
federated: bool = True
|
||||
encrypted: bool = False
|
||||
unverified_devices: bool = False
|
||||
invite_required: bool = True
|
||||
guests_allowed: bool = True
|
||||
|
||||
default_power_level: int = 0
|
||||
own_power_level: int = 0
|
||||
can_invite: bool = False
|
||||
can_kick: bool = False
|
||||
can_redact_all: bool = False
|
||||
can_send_messages: bool = False
|
||||
can_set_name: bool = False
|
||||
can_set_topic: bool = False
|
||||
can_set_avatar: bool = False
|
||||
can_set_encryption: bool = False
|
||||
can_set_join_rules: bool = False
|
||||
can_set_guest_access: bool = False
|
||||
can_set_power_levels: bool = False
|
||||
default_power_level: int = 0
|
||||
own_power_level: int = 0
|
||||
can_invite: bool = False
|
||||
can_kick: bool = False
|
||||
can_redact_all: bool = False
|
||||
can_send_messages: bool = False
|
||||
can_set_name: bool = False
|
||||
can_set_topic: bool = False
|
||||
can_set_avatar: bool = False
|
||||
can_set_encryption: bool = False
|
||||
can_set_join_rules: bool = False
|
||||
can_set_guest_access: bool = False
|
||||
can_set_power_levels: bool = False
|
||||
|
||||
last_event_date: datetime = ZERO_DATE
|
||||
last_event_date: datetime = ZERO_DATE
|
||||
|
||||
unreads: int = 0
|
||||
highlights: int = 0
|
||||
local_unreads: bool = False
|
||||
unreads: int = 0
|
||||
highlights: int = 0
|
||||
local_unreads: bool = False
|
||||
|
||||
notification_setting: RoomNotificationOverride = \
|
||||
RoomNotificationOverride.UseDefaultSettings
|
||||
notification_setting: RoomNotificationOverride = \
|
||||
RoomNotificationOverride.UseDefaultSettings
|
||||
|
||||
lexical_sorting: bool = False
|
||||
pinned: bool = False
|
||||
lexical_sorting: bool = False
|
||||
pinned: bool = False
|
||||
|
||||
# Allowed keys: "last_event_date", "unreads", "highlights", "local_unreads"
|
||||
# Keys in this dict will override their corresponding item fields for the
|
||||
# __lt__ method. This is used when we want to lock a room's position,
|
||||
# e.g. to avoid having the room move around when it is focused in the GUI
|
||||
_sort_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||
# Allowed keys: "last_event_date", "unreads", "highlights", "local_unreads"
|
||||
# Keys in this dict will override their corresponding item fields for the
|
||||
# __lt__ method. This is used when we want to lock a room's position,
|
||||
# e.g. to avoid having the room move around when it is focused in the GUI
|
||||
_sort_overrides: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def _sorting(self, key: str) -> Any:
|
||||
return self._sort_overrides.get(key, getattr(self, key))
|
||||
def _sorting(self, key: str) -> Any:
|
||||
return self._sort_overrides.get(key, getattr(self, key))
|
||||
|
||||
def __lt__(self, other: "Room") -> bool:
|
||||
by_activity = not self.lexical_sorting
|
||||
def __lt__(self, other: "Room") -> bool:
|
||||
by_activity = not self.lexical_sorting
|
||||
|
||||
return (
|
||||
self.for_account,
|
||||
other.pinned,
|
||||
self.left, # Left rooms may have an inviter_id, check them first
|
||||
bool(other.inviter_id),
|
||||
bool(by_activity and other._sorting("highlights")),
|
||||
bool(by_activity and other._sorting("unreads")),
|
||||
bool(by_activity and other._sorting("local_unreads")),
|
||||
other._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(self.display_name or self.id).lower(),
|
||||
self.id,
|
||||
return (
|
||||
self.for_account,
|
||||
other.pinned,
|
||||
self.left, # Left rooms may have an inviter_id, check them first
|
||||
bool(other.inviter_id),
|
||||
bool(by_activity and other._sorting("highlights")),
|
||||
bool(by_activity and other._sorting("unreads")),
|
||||
bool(by_activity and other._sorting("local_unreads")),
|
||||
other._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(self.display_name or self.id).lower(),
|
||||
self.id,
|
||||
|
||||
) < (
|
||||
other.for_account,
|
||||
self.pinned,
|
||||
other.left,
|
||||
bool(self.inviter_id),
|
||||
bool(by_activity and self._sorting("highlights")),
|
||||
bool(by_activity and self._sorting("unreads")),
|
||||
bool(by_activity and self._sorting("local_unreads")),
|
||||
self._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(other.display_name or other.id).lower(),
|
||||
other.id,
|
||||
)
|
||||
) < (
|
||||
other.for_account,
|
||||
self.pinned,
|
||||
other.left,
|
||||
bool(self.inviter_id),
|
||||
bool(by_activity and self._sorting("highlights")),
|
||||
bool(by_activity and self._sorting("unreads")),
|
||||
bool(by_activity and self._sorting("local_unreads")),
|
||||
self._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(other.display_name or other.id).lower(),
|
||||
other.id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class AccountOrRoom(Account, Room):
|
||||
"""The left sidepane in the GUI lists a mixture of accounts and rooms
|
||||
giving a tree view illusion. Since all items in a QML ListView must have
|
||||
the same available properties, this class inherits both
|
||||
`Account` and `Room` to fulfill that purpose.
|
||||
"""
|
||||
"""The left sidepane in the GUI lists a mixture of accounts and rooms
|
||||
giving a tree view illusion. Since all items in a QML ListView must have
|
||||
the same available properties, this class inherits both
|
||||
`Account` and `Room` to fulfill that purpose.
|
||||
"""
|
||||
|
||||
type: Union[Type[Account], Type[Room]] = Account
|
||||
account_order: int = -1
|
||||
type: Union[Type[Account], Type[Room]] = Account
|
||||
account_order: int = -1
|
||||
|
||||
def __lt__(self, other: "AccountOrRoom") -> bool: # type: ignore
|
||||
by_activity = not self.lexical_sorting
|
||||
def __lt__(self, other: "AccountOrRoom") -> bool: # type: ignore
|
||||
by_activity = not self.lexical_sorting
|
||||
|
||||
return (
|
||||
self.account_order,
|
||||
self.id if self.type is Account else self.for_account,
|
||||
other.type is Account,
|
||||
other.pinned,
|
||||
self.left,
|
||||
bool(other.inviter_id),
|
||||
bool(by_activity and other._sorting("highlights")),
|
||||
bool(by_activity and other._sorting("unreads")),
|
||||
bool(by_activity and other._sorting("local_unreads")),
|
||||
other._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(self.display_name or self.id).lower(),
|
||||
self.id,
|
||||
return (
|
||||
self.account_order,
|
||||
self.id if self.type is Account else self.for_account,
|
||||
other.type is Account,
|
||||
other.pinned,
|
||||
self.left,
|
||||
bool(other.inviter_id),
|
||||
bool(by_activity and other._sorting("highlights")),
|
||||
bool(by_activity and other._sorting("unreads")),
|
||||
bool(by_activity and other._sorting("local_unreads")),
|
||||
other._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(self.display_name or self.id).lower(),
|
||||
self.id,
|
||||
|
||||
) < (
|
||||
other.account_order,
|
||||
other.id if other.type is Account else other.for_account,
|
||||
self.type is Account,
|
||||
self.pinned,
|
||||
other.left,
|
||||
bool(self.inviter_id),
|
||||
bool(by_activity and self._sorting("highlights")),
|
||||
bool(by_activity and self._sorting("unreads")),
|
||||
bool(by_activity and self._sorting("local_unreads")),
|
||||
self._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(other.display_name or other.id).lower(),
|
||||
other.id,
|
||||
)
|
||||
) < (
|
||||
other.account_order,
|
||||
other.id if other.type is Account else other.for_account,
|
||||
self.type is Account,
|
||||
self.pinned,
|
||||
other.left,
|
||||
bool(self.inviter_id),
|
||||
bool(by_activity and self._sorting("highlights")),
|
||||
bool(by_activity and self._sorting("unreads")),
|
||||
bool(by_activity and self._sorting("local_unreads")),
|
||||
self._sorting("last_event_date") if by_activity else ZERO_DATE,
|
||||
(other.display_name or other.id).lower(),
|
||||
other.id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Member(ModelItem):
|
||||
"""A member in a matrix room."""
|
||||
"""A member in a matrix room."""
|
||||
|
||||
id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
typing: bool = False
|
||||
power_level: int = 0
|
||||
invited: bool = False
|
||||
ignored: bool = False
|
||||
profile_updated: datetime = ZERO_DATE
|
||||
last_read_event: str = ""
|
||||
id: str = field()
|
||||
display_name: str = ""
|
||||
avatar_url: str = ""
|
||||
typing: bool = False
|
||||
power_level: int = 0
|
||||
invited: bool = False
|
||||
ignored: bool = False
|
||||
profile_updated: datetime = ZERO_DATE
|
||||
last_read_event: str = ""
|
||||
|
||||
presence: Presence.State = Presence.State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = ZERO_DATE
|
||||
status_msg: str = ""
|
||||
presence: Presence.State = Presence.State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = ZERO_DATE
|
||||
status_msg: str = ""
|
||||
|
||||
def __lt__(self, other: "Member") -> bool:
|
||||
return (
|
||||
self.invited,
|
||||
other.power_level,
|
||||
self.ignored,
|
||||
Presence.State.offline if self.ignored else self.presence,
|
||||
(self.display_name or self.id[1:]).lower(),
|
||||
self.id,
|
||||
) < (
|
||||
other.invited,
|
||||
self.power_level,
|
||||
other.ignored,
|
||||
Presence.State.offline if other.ignored else other.presence,
|
||||
(other.display_name or other.id[1:]).lower(),
|
||||
other.id,
|
||||
)
|
||||
def __lt__(self, other: "Member") -> bool:
|
||||
return (
|
||||
self.invited,
|
||||
other.power_level,
|
||||
self.ignored,
|
||||
Presence.State.offline if self.ignored else self.presence,
|
||||
(self.display_name or self.id[1:]).lower(),
|
||||
self.id,
|
||||
) < (
|
||||
other.invited,
|
||||
self.power_level,
|
||||
other.ignored,
|
||||
Presence.State.offline if other.ignored else other.presence,
|
||||
(other.display_name or other.id[1:]).lower(),
|
||||
other.id,
|
||||
)
|
||||
|
||||
|
||||
class TransferStatus(AutoStrEnum):
|
||||
"""Enum describing the status of an upload operation."""
|
||||
"""Enum describing the status of an upload operation."""
|
||||
|
||||
Preparing = auto()
|
||||
Transfering = auto()
|
||||
Caching = auto()
|
||||
Error = auto()
|
||||
Preparing = auto()
|
||||
Transfering = auto()
|
||||
Caching = auto()
|
||||
Error = auto()
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Transfer(ModelItem):
|
||||
"""Represent a running or failed file upload/download operation."""
|
||||
"""Represent a running or failed file upload/download operation."""
|
||||
|
||||
id: UUID = field()
|
||||
is_upload: bool = field()
|
||||
filepath: Path = Path("-")
|
||||
id: UUID = field()
|
||||
is_upload: bool = field()
|
||||
filepath: Path = Path("-")
|
||||
|
||||
total_size: int = 0
|
||||
transferred: int = 0
|
||||
speed: float = 0
|
||||
time_left: timedelta = timedelta(0)
|
||||
paused: bool = False
|
||||
total_size: int = 0
|
||||
transferred: int = 0
|
||||
speed: float = 0
|
||||
time_left: timedelta = timedelta(0)
|
||||
paused: bool = False
|
||||
|
||||
status: TransferStatus = TransferStatus.Preparing
|
||||
error: OptionalExceptionType = type(None)
|
||||
error_args: Tuple[Any, ...] = ()
|
||||
status: TransferStatus = TransferStatus.Preparing
|
||||
error: OptionalExceptionType = type(None)
|
||||
error_args: Tuple[Any, ...] = ()
|
||||
|
||||
start_date: datetime = field(init=False, default_factory=datetime.now)
|
||||
start_date: datetime = field(init=False, default_factory=datetime.now)
|
||||
|
||||
|
||||
def __lt__(self, other: "Transfer") -> bool:
|
||||
return (self.start_date, self.id) > (other.start_date, other.id)
|
||||
def __lt__(self, other: "Transfer") -> bool:
|
||||
return (self.start_date, self.id) > (other.start_date, other.id)
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class Event(ModelItem):
|
||||
"""A matrix state event or message."""
|
||||
"""A matrix state event or message."""
|
||||
|
||||
id: str = field()
|
||||
event_id: str = field()
|
||||
event_type: Type[nio.Event] = field()
|
||||
date: datetime = field()
|
||||
sender_id: str = field()
|
||||
sender_name: str = field()
|
||||
sender_avatar: str = field()
|
||||
fetch_profile: bool = False
|
||||
id: str = field()
|
||||
event_id: str = field()
|
||||
event_type: Type[nio.Event] = field()
|
||||
date: datetime = field()
|
||||
sender_id: str = field()
|
||||
sender_name: str = field()
|
||||
sender_avatar: str = field()
|
||||
fetch_profile: bool = False
|
||||
hidden: bool = False
|
||||
|
||||
content: str = ""
|
||||
inline_content: str = ""
|
||||
reason: str = ""
|
||||
links: List[str] = field(default_factory=list)
|
||||
mentions: List[Tuple[str, str]] = field(default_factory=list)
|
||||
content: str = ""
|
||||
inline_content: str = ""
|
||||
reason: str = ""
|
||||
links: List[str] = field(default_factory=list)
|
||||
mentions: List[Tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
type_specifier: TypeSpecifier = TypeSpecifier.Unset
|
||||
reactions: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
replaced: bool = False
|
||||
content_history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
type_specifier: TypeSpecifier = TypeSpecifier.Unset
|
||||
|
||||
target_id: str = ""
|
||||
target_name: str = ""
|
||||
target_avatar: str = ""
|
||||
redacter_id: str = ""
|
||||
redacter_name: str = ""
|
||||
target_id: str = ""
|
||||
target_name: str = ""
|
||||
target_avatar: str = ""
|
||||
redacter_id: str = ""
|
||||
redacter_name: str = ""
|
||||
|
||||
# {user_id: server_timestamp} - QML can't parse dates from JSONified dicts
|
||||
last_read_by: Dict[str, int] = field(default_factory=dict)
|
||||
read_by_count: int = 0
|
||||
# {user_id: server_timestamp} - QML can't parse dates from JSONified dicts
|
||||
last_read_by: Dict[str, int] = field(default_factory=dict)
|
||||
read_by_count: int = 0
|
||||
|
||||
is_local_echo: bool = False
|
||||
source: Optional[nio.Event] = None
|
||||
is_local_echo: bool = False
|
||||
source: Optional[nio.Event] = None
|
||||
|
||||
media_url: str = ""
|
||||
media_http_url: str = ""
|
||||
media_title: str = ""
|
||||
media_width: int = 0
|
||||
media_height: int = 0
|
||||
media_duration: int = 0
|
||||
media_size: int = 0
|
||||
media_mime: str = ""
|
||||
media_crypt_dict: Dict[str, Any] = field(default_factory=dict)
|
||||
media_local_path: Union[str, Path] = ""
|
||||
media_url: str = ""
|
||||
media_http_url: str = ""
|
||||
media_title: str = ""
|
||||
media_width: int = 0
|
||||
media_height: int = 0
|
||||
media_duration: int = 0
|
||||
media_size: int = 0
|
||||
media_mime: str = ""
|
||||
media_crypt_dict: Dict[str, Any] = field(default_factory=dict)
|
||||
media_local_path: Union[str, Path] = ""
|
||||
|
||||
thumbnail_url: str = ""
|
||||
thumbnail_mime: str = ""
|
||||
thumbnail_width: int = 0
|
||||
thumbnail_height: int = 0
|
||||
thumbnail_crypt_dict: Dict[str, Any] = field(default_factory=dict)
|
||||
thumbnail_url: str = ""
|
||||
thumbnail_mime: str = ""
|
||||
thumbnail_width: int = 0
|
||||
thumbnail_height: int = 0
|
||||
thumbnail_crypt_dict: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __lt__(self, other: "Event") -> bool:
|
||||
return (self.date, self.id) > (other.date, other.id)
|
||||
def __lt__(self, other: "Event") -> bool:
|
||||
return (self.date, self.id) > (other.date, other.id)
|
||||
|
||||
@property
|
||||
def plain_content(self) -> str:
|
||||
"""Plaintext version of the event's content."""
|
||||
@property
|
||||
def plain_content(self) -> str:
|
||||
"""Plaintext version of the event's content."""
|
||||
|
||||
if isinstance(self.source, nio.RoomMessageText):
|
||||
return self.source.body
|
||||
if isinstance(self.source, nio.RoomMessageText):
|
||||
return self.source.body
|
||||
|
||||
return strip_html_tags(self.content)
|
||||
return strip_html_tags(self.content)
|
||||
|
||||
@staticmethod
|
||||
def parse_links(text: str) -> List[str]:
|
||||
"""Return list of URLs (`<a href=...>` tags) present in the content."""
|
||||
@staticmethod
|
||||
def parse_links(text: str) -> List[str]:
|
||||
"""Return list of URLs (`<a href=...>` tags) present in the content."""
|
||||
|
||||
ignore = []
|
||||
ignore = []
|
||||
|
||||
if "<mx-reply>" in text or "mention" in text:
|
||||
parser = lxml.html.etree.HTMLParser()
|
||||
tree = lxml.etree.fromstring(text, parser)
|
||||
ignore = [
|
||||
lxml.etree.tostring(matching_element)
|
||||
for ugly_disgusting_xpath in [
|
||||
# Match mx-reply > blockquote > second a (user ID link)
|
||||
"//mx-reply/blockquote/a[count(preceding-sibling::*)<=1]",
|
||||
# Match <a> tags with a mention class
|
||||
'//a[contains(concat(" ",normalize-space(@class)," ")'
|
||||
'," mention ")]',
|
||||
]
|
||||
for matching_element in tree.xpath(ugly_disgusting_xpath)
|
||||
]
|
||||
if "<mx-reply>" in text or "mention" in text:
|
||||
parser = lxml.html.etree.HTMLParser()
|
||||
tree = lxml.etree.fromstring(text, parser)
|
||||
ignore = [
|
||||
lxml.etree.tostring(matching_element)
|
||||
for ugly_disgusting_xpath in [
|
||||
# Match mx-reply > blockquote > second a (user ID link)
|
||||
"//mx-reply/blockquote/a[count(preceding-sibling::*)<=1]",
|
||||
# Match <a> tags with a mention class
|
||||
'//a[contains(concat(" ",normalize-space(@class)," ")'
|
||||
'," mention ")]',
|
||||
]
|
||||
for matching_element in tree.xpath(ugly_disgusting_xpath)
|
||||
]
|
||||
|
||||
if not text.strip():
|
||||
return []
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
return [
|
||||
url for el, attrib, url, pos in lxml.html.iterlinks(text)
|
||||
if lxml.etree.tostring(el) not in ignore
|
||||
]
|
||||
return [
|
||||
url for el, attrib, url, pos in lxml.html.iterlinks(text)
|
||||
if lxml.etree.tostring(el) not in ignore
|
||||
]
|
||||
|
||||
def serialized_field(self, field: str) -> Any:
|
||||
if field == "source":
|
||||
source_dict = asdict(self.source) if self.source else {}
|
||||
return json.dumps(source_dict)
|
||||
|
||||
return super().serialized_field(field)
|
||||
def serialized_field(self, field: str) -> Any:
|
||||
if field == "source":
|
||||
source_dict = asdict(self.source) if self.source else {}
|
||||
return json.dumps(source_dict)
|
||||
if field == "content_history":
|
||||
return serialize_value_for_qml(self.content_history)
|
||||
return super().serialized_field(field)
|
||||
|
||||
@@ -5,7 +5,7 @@ import itertools
|
||||
from contextlib import contextmanager
|
||||
from threading import RLock
|
||||
from typing import (
|
||||
TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple,
|
||||
TYPE_CHECKING, Any, Dict, Iterator, List, MutableMapping, Optional, Tuple,
|
||||
)
|
||||
|
||||
from sortedcontainers import SortedList
|
||||
@@ -15,199 +15,199 @@ from ..utils import serialize_value_for_qml
|
||||
from . import SyncId
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model_item import ModelItem
|
||||
from .proxy import ModelProxy # noqa
|
||||
from .model_item import ModelItem
|
||||
from .proxy import ModelProxy # noqa
|
||||
|
||||
|
||||
class Model(MutableMapping):
|
||||
"""A mapping of `{ModelItem.id: ModelItem}` synced between Python & QML.
|
||||
"""A mapping of `{ModelItem.id: ModelItem}` synced between Python & QML.
|
||||
|
||||
From the Python side, the model is usable like a normal dict of
|
||||
`ModelItem` subclass objects.
|
||||
Different types of `ModelItem` must not be mixed in the same model.
|
||||
From the Python side, the model is usable like a normal dict of
|
||||
`ModelItem` subclass objects.
|
||||
Different types of `ModelItem` must not be mixed in the same model.
|
||||
|
||||
When items are added, replaced, removed, have field value changes, or the
|
||||
model is cleared, corresponding `PyOtherSideEvent` are fired to inform
|
||||
QML of the changes so that it can keep its models in sync.
|
||||
When items are added, replaced, removed, have field value changes, or the
|
||||
model is cleared, corresponding `PyOtherSideEvent` are fired to inform
|
||||
QML of the changes so that it can keep its models in sync.
|
||||
|
||||
Items in the model are kept sorted using the `ModelItem` subclass `__lt__`.
|
||||
"""
|
||||
Items in the model are kept sorted using the `ModelItem` subclass `__lt__`.
|
||||
"""
|
||||
|
||||
instances: Dict[SyncId, "Model"] = {}
|
||||
proxies: Dict[SyncId, "ModelProxy"] = {}
|
||||
instances: Dict[SyncId, "Model"] = {}
|
||||
proxies: Dict[SyncId, "ModelProxy"] = {}
|
||||
|
||||
|
||||
def __init__(self, sync_id: Optional[SyncId]) -> None:
|
||||
self.sync_id: Optional[SyncId] = sync_id
|
||||
self.write_lock: RLock = RLock()
|
||||
self._data: Dict[Any, "ModelItem"] = {}
|
||||
self._sorted_data: SortedList["ModelItem"] = SortedList()
|
||||
def __init__(self, sync_id: Optional[SyncId]) -> None:
|
||||
self.sync_id: Optional[SyncId] = sync_id
|
||||
self.write_lock: RLock = RLock()
|
||||
self._data: Dict[Any, "ModelItem"] = {}
|
||||
self._sorted_data: SortedList["ModelItem"] = SortedList()
|
||||
|
||||
self.take_items_ownership: bool = True
|
||||
self.take_items_ownership: bool = True
|
||||
|
||||
# [(index, item.id), ...]
|
||||
self._active_batch_removed: Optional[List[Tuple[int, Any]]] = None
|
||||
# [(index, item.id), ...]
|
||||
self._active_batch_removed: Optional[List[Tuple[int, Any]]] = None
|
||||
|
||||
if self.sync_id:
|
||||
self.instances[self.sync_id] = self
|
||||
if self.sync_id:
|
||||
self.instances[self.sync_id] = self
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Provide a full representation of the model and its content."""
|
||||
def __repr__(self) -> str:
|
||||
"""Provide a full representation of the model and its content."""
|
||||
|
||||
return "%s(sync_id=%s, %s)" % (
|
||||
type(self).__name__, self.sync_id, self._data,
|
||||
)
|
||||
return "%s(sync_id=%s, %s)" % (
|
||||
type(self).__name__, self.sync_id, self._data,
|
||||
)
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Provide a short "<sync_id>: <num> items" representation."""
|
||||
return f"{self.sync_id}: {len(self)} items"
|
||||
def __str__(self) -> str:
|
||||
"""Provide a short "<sync_id>: <num> items" representation."""
|
||||
return f"{self.sync_id}: {len(self)} items"
|
||||
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._data[key]
|
||||
def __getitem__(self, key):
|
||||
return self._data[key]
|
||||
|
||||
|
||||
def __setitem__(
|
||||
self,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
with self.write_lock:
|
||||
existing = self._data.get(key)
|
||||
new = value
|
||||
def __setitem__(
|
||||
self,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
with self.write_lock:
|
||||
existing = self._data.get(key)
|
||||
new = value
|
||||
|
||||
# Collect changed fields
|
||||
# Collect changed fields
|
||||
|
||||
changed_fields = _changed_fields or {}
|
||||
changed_fields = _changed_fields or {}
|
||||
|
||||
if not changed_fields:
|
||||
for field in new.__dataclass_fields__: # type: ignore
|
||||
if field.startswith("_"):
|
||||
continue
|
||||
if not changed_fields:
|
||||
for field in new.__dataclass_fields__: # type: ignore
|
||||
if field.startswith("_"):
|
||||
continue
|
||||
|
||||
changed = True
|
||||
changed = True
|
||||
|
||||
if existing:
|
||||
changed = \
|
||||
getattr(new, field) != getattr(existing, field)
|
||||
if existing:
|
||||
changed = \
|
||||
getattr(new, field) != getattr(existing, field)
|
||||
|
||||
if changed:
|
||||
changed_fields[field] = new.serialized_field(field)
|
||||
if changed:
|
||||
changed_fields[field] = new.serialized_field(field)
|
||||
|
||||
# Set parent model on new item
|
||||
# Set parent model on new item
|
||||
|
||||
if self.sync_id and self.take_items_ownership:
|
||||
new.parent_model = self
|
||||
if self.sync_id and self.take_items_ownership:
|
||||
new.parent_model = self
|
||||
|
||||
# Insert into sorted data
|
||||
# Insert into sorted data
|
||||
|
||||
index_then = None
|
||||
index_then = None
|
||||
|
||||
if existing:
|
||||
index_then = self._sorted_data.index(existing)
|
||||
del self._sorted_data[index_then]
|
||||
if existing:
|
||||
index_then = self._sorted_data.index(existing)
|
||||
del self._sorted_data[index_then]
|
||||
|
||||
self._sorted_data.add(new)
|
||||
index_now = self._sorted_data.index(new)
|
||||
self._sorted_data.add(new)
|
||||
index_now = self._sorted_data.index(new)
|
||||
|
||||
# Insert into dict data
|
||||
# Insert into dict data
|
||||
|
||||
self._data[key] = new
|
||||
self._data[key] = new
|
||||
|
||||
# Callbacks
|
||||
# Callbacks
|
||||
|
||||
for sync_id, proxy in self.proxies.items():
|
||||
if sync_id != self.sync_id:
|
||||
proxy.source_item_set(self, key, value)
|
||||
for sync_id, proxy in self.proxies.items():
|
||||
if sync_id != self.sync_id:
|
||||
proxy.source_item_set(self, key, value)
|
||||
|
||||
# Emit PyOtherSide event
|
||||
# Emit PyOtherSide event
|
||||
|
||||
if self.sync_id and (index_then != index_now or changed_fields):
|
||||
ModelItemSet(
|
||||
self.sync_id, index_then, index_now, changed_fields,
|
||||
)
|
||||
if self.sync_id and (index_then != index_now or changed_fields):
|
||||
ModelItemSet(
|
||||
self.sync_id, index_then, index_now, changed_fields,
|
||||
)
|
||||
|
||||
|
||||
def __delitem__(self, key) -> None:
|
||||
with self.write_lock:
|
||||
item = self._data[key]
|
||||
def __delitem__(self, key) -> None:
|
||||
with self.write_lock:
|
||||
item = self._data[key]
|
||||
|
||||
if self.sync_id and self.take_items_ownership:
|
||||
item.parent_model = None
|
||||
if self.sync_id and self.take_items_ownership:
|
||||
item.parent_model = None
|
||||
|
||||
del self._data[key]
|
||||
del self._data[key]
|
||||
|
||||
index = self._sorted_data.index(item)
|
||||
del self._sorted_data[index]
|
||||
index = self._sorted_data.index(item)
|
||||
del self._sorted_data[index]
|
||||
|
||||
for sync_id, proxy in self.proxies.items():
|
||||
if sync_id != self.sync_id:
|
||||
proxy.source_item_deleted(self, key)
|
||||
for sync_id, proxy in self.proxies.items():
|
||||
if sync_id != self.sync_id:
|
||||
proxy.source_item_deleted(self, key)
|
||||
|
||||
if self.sync_id:
|
||||
if self._active_batch_removed is None:
|
||||
i = serialize_value_for_qml(item.id, json_list_dicts=True)
|
||||
ModelItemDeleted(self.sync_id, index, 1, (i,))
|
||||
else:
|
||||
self._active_batch_removed.append((index, item.id))
|
||||
if self.sync_id:
|
||||
if self._active_batch_removed is None:
|
||||
i = serialize_value_for_qml(item.id, json_list_dicts=True)
|
||||
ModelItemDeleted(self.sync_id, index, 1, (i,))
|
||||
else:
|
||||
self._active_batch_removed.append((index, item.id))
|
||||
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self._data)
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self._data)
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
def __len__(self) -> int:
|
||||
return len(self._data)
|
||||
|
||||
|
||||
def __lt__(self, other: "Model") -> bool:
|
||||
"""Sort `Model` objects lexically by `sync_id`."""
|
||||
return str(self.sync_id) < str(other.sync_id)
|
||||
def __lt__(self, other: "Model") -> bool:
|
||||
"""Sort `Model` objects lexically by `sync_id`."""
|
||||
return str(self.sync_id) < str(other.sync_id)
|
||||
|
||||
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
if self.sync_id:
|
||||
ModelCleared(self.sync_id)
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
if self.sync_id:
|
||||
ModelCleared(self.sync_id)
|
||||
|
||||
|
||||
def copy(self, sync_id: Optional[SyncId] = None) -> "Model":
|
||||
new = type(self)(sync_id=sync_id)
|
||||
new.update(self)
|
||||
return new
|
||||
def copy(self, sync_id: Optional[SyncId] = None) -> "Model":
|
||||
new = type(self)(sync_id=sync_id)
|
||||
new.update(self)
|
||||
return new
|
||||
|
||||
|
||||
@contextmanager
|
||||
def batch_remove(self):
|
||||
"""Context manager that accumulates item removal events.
|
||||
@contextmanager
|
||||
def batch_remove(self):
|
||||
"""Context manager that accumulates item removal events.
|
||||
|
||||
When the context manager exits, sequences of removed items are grouped
|
||||
and one `ModelItemDeleted` pyotherside event is fired per sequence.
|
||||
"""
|
||||
When the context manager exits, sequences of removed items are grouped
|
||||
and one `ModelItemDeleted` pyotherside event is fired per sequence.
|
||||
"""
|
||||
|
||||
with self.write_lock:
|
||||
try:
|
||||
self._active_batch_removed = []
|
||||
yield None
|
||||
finally:
|
||||
batch = self._active_batch_removed
|
||||
groups = [
|
||||
list(group) for item, group in
|
||||
itertools.groupby(batch, key=lambda x: x[0])
|
||||
]
|
||||
with self.write_lock:
|
||||
try:
|
||||
self._active_batch_removed = []
|
||||
yield None
|
||||
finally:
|
||||
batch = self._active_batch_removed
|
||||
groups = [
|
||||
list(group) for item, group in
|
||||
itertools.groupby(batch, key=lambda x: x[0])
|
||||
]
|
||||
|
||||
def serialize_id(id_):
|
||||
return serialize_value_for_qml(id_, json_list_dicts=True)
|
||||
def serialize_id(id_):
|
||||
return serialize_value_for_qml(id_, json_list_dicts=True)
|
||||
|
||||
for group in groups:
|
||||
ModelItemDeleted(
|
||||
self.sync_id,
|
||||
index = group[0][0],
|
||||
count = len(group),
|
||||
ids = [serialize_id(item[1]) for item in group],
|
||||
)
|
||||
for group in groups:
|
||||
ModelItemDeleted(
|
||||
self.sync_id,
|
||||
index = group[0][0],
|
||||
count = len(group),
|
||||
ids = [serialize_id(item[1]) for item in group],
|
||||
)
|
||||
|
||||
self._active_batch_removed = None
|
||||
self._active_batch_removed = None
|
||||
|
||||
@@ -8,122 +8,122 @@ from ..pyotherside_events import ModelItemSet
|
||||
from ..utils import serialize_value_for_qml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model import Model
|
||||
from .model import Model
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ModelItem:
|
||||
"""Base class for items stored inside a `Model`.
|
||||
"""Base class for items stored inside a `Model`.
|
||||
|
||||
This class must be subclassed and not used directly.
|
||||
All subclasses must use the `@dataclass(eq=False)` decorator.
|
||||
This class must be subclassed and not used directly.
|
||||
All subclasses must use the `@dataclass(eq=False)` decorator.
|
||||
|
||||
Subclasses are also expected to implement `__lt__()`,
|
||||
to provide support for comparisons with the `<`, `>`, `<=`, `=>` operators
|
||||
and thus allow a `Model` to keep its data sorted.
|
||||
Subclasses are also expected to implement `__lt__()`,
|
||||
to provide support for comparisons with the `<`, `>`, `<=`, `=>` operators
|
||||
and thus allow a `Model` to keep its data sorted.
|
||||
|
||||
Make sure to respect SortedList requirements when implementing `__lt__()`:
|
||||
http://www.grantjenks.com/docs/sortedcontainers/introduction.html#caveats
|
||||
"""
|
||||
Make sure to respect SortedList requirements when implementing `__lt__()`:
|
||||
http://www.grantjenks.com/docs/sortedcontainers/introduction.html#caveats
|
||||
"""
|
||||
|
||||
id: Any = field()
|
||||
id: Any = field()
|
||||
|
||||
|
||||
def __new__(cls, *_args, **_kwargs) -> "ModelItem":
|
||||
cls.parent_model: Optional[Model] = None
|
||||
return super().__new__(cls)
|
||||
def __new__(cls, *_args, **_kwargs) -> "ModelItem":
|
||||
cls.parent_model: Optional[Model] = None
|
||||
return super().__new__(cls)
|
||||
|
||||
|
||||
def __setattr__(self, name: str, value) -> None:
|
||||
self.set_fields(**{name: value})
|
||||
def __setattr__(self, name: str, value) -> None:
|
||||
self.set_fields(**{name: value})
|
||||
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise NotImplementedError()
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@property
|
||||
def serialized(self) -> Dict[str, Any]:
|
||||
"""Return this item as a dict ready to be passed to QML."""
|
||||
@property
|
||||
def serialized(self) -> Dict[str, Any]:
|
||||
"""Return this item as a dict ready to be passed to QML."""
|
||||
|
||||
return {
|
||||
name: self.serialized_field(name)
|
||||
for name in self.__dataclass_fields__ # type: ignore
|
||||
if not name.startswith("_")
|
||||
}
|
||||
return {
|
||||
name: self.serialized_field(name)
|
||||
for name in self.__dataclass_fields__ # type: ignore
|
||||
if not name.startswith("_")
|
||||
}
|
||||
|
||||
|
||||
def serialized_field(self, field: str) -> Any:
|
||||
"""Return a field's value in a form suitable for passing to QML."""
|
||||
def serialized_field(self, field: str) -> Any:
|
||||
"""Return a field's value in a form suitable for passing to QML."""
|
||||
|
||||
value = getattr(self, field)
|
||||
return serialize_value_for_qml(value, json_list_dicts=True)
|
||||
value = getattr(self, field)
|
||||
return serialize_value_for_qml(value, json_list_dicts=True)
|
||||
|
||||
|
||||
def set_fields(self, _force: bool = False, **fields: Any) -> None:
|
||||
"""Set one or more field's value and call `ModelItem.notify_change`.
|
||||
def set_fields(self, _force: bool = False, **fields: Any) -> None:
|
||||
"""Set one or more field's value and call `ModelItem.notify_change`.
|
||||
|
||||
For efficiency, to change multiple fields, this method should be
|
||||
used rather than setting them one after another with `=` or `setattr`.
|
||||
"""
|
||||
For efficiency, to change multiple fields, this method should be
|
||||
used rather than setting them one after another with `=` or `setattr`.
|
||||
"""
|
||||
|
||||
parent = self.parent_model
|
||||
parent = self.parent_model
|
||||
|
||||
# If we're currently being created or haven't been put in a model yet:
|
||||
if not parent:
|
||||
for name, value in fields.items():
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
# If we're currently being created or haven't been put in a model yet:
|
||||
if not parent:
|
||||
for name, value in fields.items():
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
with parent.write_lock:
|
||||
qml_changes = {}
|
||||
changes = {
|
||||
name: value for name, value in fields.items()
|
||||
if _force or getattr(self, name) != value
|
||||
}
|
||||
with parent.write_lock:
|
||||
qml_changes = {}
|
||||
changes = {
|
||||
name: value for name, value in fields.items()
|
||||
if _force or getattr(self, name) != value
|
||||
}
|
||||
|
||||
if not changes:
|
||||
return
|
||||
if not changes:
|
||||
return
|
||||
|
||||
# To avoid corrupting the SortedList, we have to take out the item,
|
||||
# apply the field changes, *then* add it back in.
|
||||
# To avoid corrupting the SortedList, we have to take out the item,
|
||||
# apply the field changes, *then* add it back in.
|
||||
|
||||
index_then = parent._sorted_data.index(self)
|
||||
del parent._sorted_data[index_then]
|
||||
index_then = parent._sorted_data.index(self)
|
||||
del parent._sorted_data[index_then]
|
||||
|
||||
for name, value in changes.items():
|
||||
super().__setattr__(name, value)
|
||||
is_field = name in self.__dataclass_fields__ # type: ignore
|
||||
for name, value in changes.items():
|
||||
super().__setattr__(name, value)
|
||||
is_field = name in self.__dataclass_fields__ # type: ignore
|
||||
|
||||
if is_field and not name.startswith("_"):
|
||||
qml_changes[name] = self.serialized_field(name)
|
||||
if is_field and not name.startswith("_"):
|
||||
qml_changes[name] = self.serialized_field(name)
|
||||
|
||||
parent._sorted_data.add(self)
|
||||
index_now = parent._sorted_data.index(self)
|
||||
index_change = index_then != index_now
|
||||
parent._sorted_data.add(self)
|
||||
index_now = parent._sorted_data.index(self)
|
||||
index_change = index_then != index_now
|
||||
|
||||
# Now, inform QML about changed dataclass fields if any.
|
||||
# Now, inform QML about changed dataclass fields if any.
|
||||
|
||||
if not parent.sync_id or (not qml_changes and not index_change):
|
||||
return
|
||||
if not parent.sync_id or (not qml_changes and not index_change):
|
||||
return
|
||||
|
||||
ModelItemSet(parent.sync_id, index_then, index_now, qml_changes)
|
||||
ModelItemSet(parent.sync_id, index_then, index_now, qml_changes)
|
||||
|
||||
# Inform any proxy connected to the parent model of the field changes
|
||||
# Inform any proxy connected to the parent model of the field changes
|
||||
|
||||
for sync_id, proxy in parent.proxies.items():
|
||||
if sync_id != parent.sync_id:
|
||||
proxy.source_item_set(parent, self.id, self, qml_changes)
|
||||
for sync_id, proxy in parent.proxies.items():
|
||||
if sync_id != parent.sync_id:
|
||||
proxy.source_item_set(parent, self.id, self, qml_changes)
|
||||
|
||||
|
||||
def notify_change(self, *fields: str) -> None:
|
||||
"""Notify the parent model that fields of this item have changed.
|
||||
def notify_change(self, *fields: str) -> None:
|
||||
"""Notify the parent model that fields of this item have changed.
|
||||
|
||||
The model cannot automatically detect changes inside
|
||||
object fields, such as list or dicts having their data modified.
|
||||
In these cases, this method should be called.
|
||||
"""
|
||||
The model cannot automatically detect changes inside
|
||||
object fields, such as list or dicts having their data modified.
|
||||
In these cases, this method should be called.
|
||||
"""
|
||||
|
||||
kwargs = {name: getattr(self, name) for name in fields}
|
||||
kwargs["_force"] = True
|
||||
self.set_fields(**kwargs)
|
||||
kwargs = {name: getattr(self, name) for name in fields}
|
||||
kwargs["_force"] = True
|
||||
self.set_fields(**kwargs)
|
||||
|
||||
@@ -8,66 +8,66 @@ from typing import Dict, List, Union
|
||||
from . import SyncId
|
||||
from .model import Model
|
||||
from .special_models import (
|
||||
AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers,
|
||||
MatchingAccounts,
|
||||
AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers,
|
||||
MatchingAccounts,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelStore(UserDict):
|
||||
"""Dict of sync ID keys and `Model` values.
|
||||
"""Dict of sync ID keys and `Model` values.
|
||||
|
||||
The dict keys must be the sync ID of `Model` values.
|
||||
If a non-existent key is accessed, a corresponding `Model` will be
|
||||
created, put into the internal `data` dict and returned.
|
||||
"""
|
||||
The dict keys must be the sync ID of `Model` values.
|
||||
If a non-existent key is accessed, a corresponding `Model` will be
|
||||
created, put into the internal `data` dict and returned.
|
||||
"""
|
||||
|
||||
data: Dict[SyncId, Model] = field(default_factory=dict)
|
||||
data: Dict[SyncId, Model] = field(default_factory=dict)
|
||||
|
||||
|
||||
def __missing__(self, key: SyncId) -> Model:
|
||||
"""When accessing a non-existent model, create and return it.
|
||||
def __missing__(self, key: SyncId) -> Model:
|
||||
"""When accessing a non-existent model, create and return it.
|
||||
|
||||
Special models rather than a generic `Model` object may be returned
|
||||
depending on the passed key.
|
||||
"""
|
||||
Special models rather than a generic `Model` object may be returned
|
||||
depending on the passed key.
|
||||
"""
|
||||
|
||||
is_tuple = isinstance(key, tuple)
|
||||
is_tuple = isinstance(key, tuple)
|
||||
|
||||
model: Model
|
||||
model: Model
|
||||
|
||||
if key == "all_rooms":
|
||||
model = AllRooms(self["accounts"])
|
||||
elif key == "matching_accounts":
|
||||
model = MatchingAccounts(self["all_rooms"])
|
||||
elif key == "filtered_homeservers":
|
||||
model = FilteredHomeservers()
|
||||
elif is_tuple and len(key) == 3 and key[2] == "filtered_members":
|
||||
model = FilteredMembers(user_id=key[0], room_id=key[1])
|
||||
elif is_tuple and len(key) == 3 and key[2] == "autocompleted_members":
|
||||
model = AutoCompletedMembers(user_id=key[0], room_id=key[1])
|
||||
else:
|
||||
model = Model(sync_id=key)
|
||||
if key == "all_rooms":
|
||||
model = AllRooms(self["accounts"])
|
||||
elif key == "matching_accounts":
|
||||
model = MatchingAccounts(self["all_rooms"])
|
||||
elif key == "filtered_homeservers":
|
||||
model = FilteredHomeservers()
|
||||
elif is_tuple and len(key) == 3 and key[2] == "filtered_members":
|
||||
model = FilteredMembers(user_id=key[0], room_id=key[1])
|
||||
elif is_tuple and len(key) == 3 and key[2] == "autocompleted_members":
|
||||
model = AutoCompletedMembers(user_id=key[0], room_id=key[1])
|
||||
else:
|
||||
model = Model(sync_id=key)
|
||||
|
||||
self.data[key] = model
|
||||
return model
|
||||
self.data[key] = model
|
||||
return model
|
||||
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Provide a nice overview of stored models when `print()` called."""
|
||||
def __str__(self) -> str:
|
||||
"""Provide a nice overview of stored models when `print()` called."""
|
||||
|
||||
return "%s(\n %s\n)" % (
|
||||
type(self).__name__,
|
||||
"\n ".join(sorted(str(v) for v in self.values())),
|
||||
)
|
||||
return "%s(\n %s\n)" % (
|
||||
type(self).__name__,
|
||||
"\n ".join(sorted(str(v) for v in self.values())),
|
||||
)
|
||||
|
||||
|
||||
async def ensure_exists_from_qml(
|
||||
self, sync_id: Union[SyncId, List[str]],
|
||||
) -> None:
|
||||
"""Create model if it doesn't exist. Should only be called by QML."""
|
||||
async def ensure_exists_from_qml(
|
||||
self, sync_id: Union[SyncId, List[str]],
|
||||
) -> None:
|
||||
"""Create model if it doesn't exist. Should only be called by QML."""
|
||||
|
||||
if isinstance(sync_id, list): # QML can't pass tuples
|
||||
sync_id = tuple(sync_id)
|
||||
if isinstance(sync_id, list): # QML can't pass tuples
|
||||
sync_id = tuple(sync_id)
|
||||
|
||||
self[sync_id] # will call __missing__ if needed
|
||||
self[sync_id] # will call __missing__ if needed
|
||||
|
||||
@@ -8,68 +8,68 @@ from . import SyncId
|
||||
from .model import Model
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .model_item import ModelItem
|
||||
from .model_item import ModelItem
|
||||
|
||||
|
||||
class ModelProxy(Model):
|
||||
"""Proxies data from one or more `Model` objects."""
|
||||
"""Proxies data from one or more `Model` objects."""
|
||||
|
||||
def __init__(self, sync_id: SyncId) -> None:
|
||||
super().__init__(sync_id)
|
||||
self.take_items_ownership = False
|
||||
Model.proxies[sync_id] = self
|
||||
def __init__(self, sync_id: SyncId) -> None:
|
||||
super().__init__(sync_id)
|
||||
self.take_items_ownership = False
|
||||
Model.proxies[sync_id] = self
|
||||
|
||||
with self.write_lock:
|
||||
for sync_id, model in Model.instances.items():
|
||||
if sync_id != self.sync_id and self.accept_source(model):
|
||||
for key, item in model.items():
|
||||
self.source_item_set(model, key, item)
|
||||
with self.write_lock:
|
||||
for sync_id, model in Model.instances.items():
|
||||
if sync_id != self.sync_id and self.accept_source(model):
|
||||
for key, item in model.items():
|
||||
self.source_item_set(model, key, item)
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
"""Return whether passed `Model` should be proxied by this proxy."""
|
||||
return True
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
"""Return whether passed `Model` should be proxied by this proxy."""
|
||||
return True
|
||||
|
||||
|
||||
def convert_item(self, item: "ModelItem") -> "ModelItem":
|
||||
"""Take a source `ModelItem`, return an appropriate one for proxy.
|
||||
def convert_item(self, item: "ModelItem") -> "ModelItem":
|
||||
"""Take a source `ModelItem`, return an appropriate one for proxy.
|
||||
|
||||
By default, this returns the passed item unchanged.
|
||||
By default, this returns the passed item unchanged.
|
||||
|
||||
Due to QML `ListModel` restrictions, if multiple source models
|
||||
containing different subclasses of `ModelItem` are proxied,
|
||||
they should be converted to a same `ModelItem`
|
||||
subclass by overriding this function.
|
||||
"""
|
||||
return copy(item)
|
||||
Due to QML `ListModel` restrictions, if multiple source models
|
||||
containing different subclasses of `ModelItem` are proxied,
|
||||
they should be converted to a same `ModelItem`
|
||||
subclass by overriding this function.
|
||||
"""
|
||||
return copy(item)
|
||||
|
||||
|
||||
def source_item_set(
|
||||
self,
|
||||
source: Model,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a source model item is added or changed."""
|
||||
def source_item_set(
|
||||
self,
|
||||
source: Model,
|
||||
key,
|
||||
value: "ModelItem",
|
||||
_changed_fields: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a source model item is added or changed."""
|
||||
|
||||
if self.accept_source(source):
|
||||
value = self.convert_item(value)
|
||||
self.__setitem__((source.sync_id, key), value, _changed_fields)
|
||||
if self.accept_source(source):
|
||||
value = self.convert_item(value)
|
||||
self.__setitem__((source.sync_id, key), value, _changed_fields)
|
||||
|
||||
|
||||
def source_item_deleted(self, source: Model, key) -> None:
|
||||
"""Called when a source model item is removed."""
|
||||
def source_item_deleted(self, source: Model, key) -> None:
|
||||
"""Called when a source model item is removed."""
|
||||
|
||||
if self.accept_source(source):
|
||||
del self[source.sync_id, key]
|
||||
if self.accept_source(source):
|
||||
del self[source.sync_id, key]
|
||||
|
||||
|
||||
def source_cleared(self, source: Model) -> None:
|
||||
"""Called when a source model is cleared."""
|
||||
def source_cleared(self, source: Model) -> None:
|
||||
"""Called when a source model is cleared."""
|
||||
|
||||
if self.accept_source(source):
|
||||
with self.batch_remove():
|
||||
for source_sync_id, key in self.copy():
|
||||
if source_sync_id == source.sync_id:
|
||||
del self[source_sync_id, key]
|
||||
if self.accept_source(source):
|
||||
with self.batch_remove():
|
||||
for source_sync_id, key in self.copy():
|
||||
if source_sync_id == source.sync_id:
|
||||
del self[source_sync_id, key]
|
||||
|
||||
@@ -11,143 +11,143 @@ from .model_item import ModelItem
|
||||
|
||||
|
||||
class AllRooms(FieldSubstringFilter):
|
||||
"""Flat filtered list of all accounts and their rooms."""
|
||||
"""Flat filtered list of all accounts and their rooms."""
|
||||
|
||||
def __init__(self, accounts: Model) -> None:
|
||||
self.accounts = accounts
|
||||
self._collapsed: Set[str] = set()
|
||||
def __init__(self, accounts: Model) -> None:
|
||||
self.accounts = accounts
|
||||
self._collapsed: Set[str] = set()
|
||||
|
||||
super().__init__(sync_id="all_rooms", fields=("display_name",))
|
||||
self.items_changed_callbacks.append(self.refilter_accounts)
|
||||
super().__init__(sync_id="all_rooms", fields=("display_name",))
|
||||
self.items_changed_callbacks.append(self.refilter_accounts)
|
||||
|
||||
|
||||
def set_account_collapse(self, user_id: str, collapsed: bool) -> None:
|
||||
"""Set whether the rooms for an account should be filtered out."""
|
||||
def set_account_collapse(self, user_id: str, collapsed: bool) -> None:
|
||||
"""Set whether the rooms for an account should be filtered out."""
|
||||
|
||||
def only_if(item):
|
||||
return item.type is Room and item.for_account == user_id
|
||||
def only_if(item):
|
||||
return item.type is Room and item.for_account == user_id
|
||||
|
||||
if collapsed and user_id not in self._collapsed:
|
||||
self._collapsed.add(user_id)
|
||||
self.refilter(only_if)
|
||||
if collapsed and user_id not in self._collapsed:
|
||||
self._collapsed.add(user_id)
|
||||
self.refilter(only_if)
|
||||
|
||||
if not collapsed and user_id in self._collapsed:
|
||||
self._collapsed.remove(user_id)
|
||||
self.refilter(only_if)
|
||||
if not collapsed and user_id in self._collapsed:
|
||||
self._collapsed.remove(user_id)
|
||||
self.refilter(only_if)
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "accounts" or (
|
||||
isinstance(source.sync_id, tuple) and
|
||||
len(source.sync_id) == 2 and
|
||||
source.sync_id[1] == "rooms"
|
||||
)
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "accounts" or (
|
||||
isinstance(source.sync_id, tuple) and
|
||||
len(source.sync_id) == 2 and
|
||||
source.sync_id[1] == "rooms"
|
||||
)
|
||||
|
||||
|
||||
def convert_item(self, item: ModelItem) -> AccountOrRoom:
|
||||
return AccountOrRoom(
|
||||
**asdict(item),
|
||||
type = type(item), # type: ignore
|
||||
def convert_item(self, item: ModelItem) -> AccountOrRoom:
|
||||
return AccountOrRoom(
|
||||
**asdict(item),
|
||||
type = type(item), # type: ignore
|
||||
|
||||
account_order =
|
||||
item.order if isinstance(item, Account) else
|
||||
self.accounts[item.for_account].order, # type: ignore
|
||||
)
|
||||
account_order =
|
||||
item.order if isinstance(item, Account) else
|
||||
self.accounts[item.for_account].order, # type: ignore
|
||||
)
|
||||
|
||||
|
||||
def accept_item(self, item: ModelItem) -> bool:
|
||||
assert isinstance(item, AccountOrRoom) # nosec
|
||||
def accept_item(self, item: ModelItem) -> bool:
|
||||
assert isinstance(item, AccountOrRoom) # nosec
|
||||
|
||||
if not self.filter and \
|
||||
item.type is Room and \
|
||||
item.for_account in self._collapsed:
|
||||
return False
|
||||
if not self.filter and \
|
||||
item.type is Room and \
|
||||
item.for_account in self._collapsed:
|
||||
return False
|
||||
|
||||
matches_filter = super().accept_item(item)
|
||||
matches_filter = super().accept_item(item)
|
||||
|
||||
if item.type is not Account or not self.filter:
|
||||
return matches_filter
|
||||
if item.type is not Account or not self.filter:
|
||||
return matches_filter
|
||||
|
||||
return next(
|
||||
(i for i in self.values() if i.for_account == item.id), False,
|
||||
)
|
||||
return next(
|
||||
(i for i in self.values() if i.for_account == item.id), False,
|
||||
)
|
||||
|
||||
|
||||
def refilter_accounts(self) -> None:
|
||||
self.refilter(lambda i: i.type is Account) # type: ignore
|
||||
def refilter_accounts(self) -> None:
|
||||
self.refilter(lambda i: i.type is Account) # type: ignore
|
||||
|
||||
|
||||
class MatchingAccounts(ModelFilter):
|
||||
"""List of our accounts in `AllRooms` with at least one matching room if
|
||||
a `filter` is set, else list of all accounts.
|
||||
"""
|
||||
"""List of our accounts in `AllRooms` with at least one matching room if
|
||||
a `filter` is set, else list of all accounts.
|
||||
"""
|
||||
|
||||
def __init__(self, all_rooms: AllRooms) -> None:
|
||||
self.all_rooms = all_rooms
|
||||
self.all_rooms.items_changed_callbacks.append(self.refilter)
|
||||
def __init__(self, all_rooms: AllRooms) -> None:
|
||||
self.all_rooms = all_rooms
|
||||
self.all_rooms.items_changed_callbacks.append(self.refilter)
|
||||
|
||||
super().__init__(sync_id="matching_accounts")
|
||||
super().__init__(sync_id="matching_accounts")
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "accounts"
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "accounts"
|
||||
|
||||
|
||||
def accept_item(self, item: ModelItem) -> bool:
|
||||
if not self.all_rooms.filter:
|
||||
return True
|
||||
def accept_item(self, item: ModelItem) -> bool:
|
||||
if not self.all_rooms.filter:
|
||||
return True
|
||||
|
||||
return next(
|
||||
(i for i in self.all_rooms.values() if i.id == item.id),
|
||||
False,
|
||||
)
|
||||
return next(
|
||||
(i for i in self.all_rooms.values() if i.id == item.id),
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
class FilteredMembers(FieldSubstringFilter):
|
||||
"""Filtered list of members for a room."""
|
||||
"""Filtered list of members for a room."""
|
||||
|
||||
def __init__(self, user_id: str, room_id: str) -> None:
|
||||
self.user_id = user_id
|
||||
self.room_id = room_id
|
||||
sync_id = (user_id, room_id, "filtered_members")
|
||||
def __init__(self, user_id: str, room_id: str) -> None:
|
||||
self.user_id = user_id
|
||||
self.room_id = room_id
|
||||
sync_id = (user_id, room_id, "filtered_members")
|
||||
|
||||
super().__init__(sync_id=sync_id, fields=("display_name",))
|
||||
super().__init__(sync_id=sync_id, fields=("display_name",))
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == (self.user_id, self.room_id, "members")
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == (self.user_id, self.room_id, "members")
|
||||
|
||||
|
||||
class AutoCompletedMembers(FieldStringFilter):
|
||||
"""Filtered list of mentionable members for tab-completion."""
|
||||
"""Filtered list of mentionable members for tab-completion."""
|
||||
|
||||
def __init__(self, user_id: str, room_id: str) -> None:
|
||||
self.user_id = user_id
|
||||
self.room_id = room_id
|
||||
sync_id = (user_id, room_id, "autocompleted_members")
|
||||
def __init__(self, user_id: str, room_id: str) -> None:
|
||||
self.user_id = user_id
|
||||
self.room_id = room_id
|
||||
sync_id = (user_id, room_id, "autocompleted_members")
|
||||
|
||||
super().__init__(
|
||||
sync_id = sync_id,
|
||||
fields = ("display_name", "id"),
|
||||
no_filter_accept_all_items = False,
|
||||
)
|
||||
super().__init__(
|
||||
sync_id = sync_id,
|
||||
fields = ("display_name", "id"),
|
||||
no_filter_accept_all_items = False,
|
||||
)
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == (self.user_id, self.room_id, "members")
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == (self.user_id, self.room_id, "members")
|
||||
|
||||
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
fields["id"] = fields["id"][1:] # remove leading @
|
||||
return super().match(fields, filtr)
|
||||
def match(self, fields: Dict[str, str], filtr: str) -> bool:
|
||||
fields["id"] = fields["id"][1:] # remove leading @
|
||||
return super().match(fields, filtr)
|
||||
|
||||
|
||||
class FilteredHomeservers(FieldSubstringFilter):
|
||||
"""Filtered list of public Matrix homeservers."""
|
||||
"""Filtered list of public Matrix homeservers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(sync_id="filtered_homeservers", fields=("id", "name"))
|
||||
def __init__(self) -> None:
|
||||
super().__init__(sync_id="filtered_homeservers", fields=("id", "name"))
|
||||
|
||||
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "homeservers"
|
||||
def accept_source(self, source: Model) -> bool:
|
||||
return source.sync_id == "homeservers"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,46 +2,46 @@ from collections import UserDict
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .section import Section
|
||||
from .section import Section
|
||||
|
||||
from .. import color
|
||||
|
||||
PCN_GLOBALS: Dict[str, Any] = {
|
||||
"color": color.Color,
|
||||
"hsluv": color.hsluv,
|
||||
"hsluva": color.hsluva,
|
||||
"hsl": color.hsl,
|
||||
"hsla": color.hsla,
|
||||
"rgb": color.rgb,
|
||||
"rgba": color.rgba,
|
||||
"color": color.Color,
|
||||
"hsluv": color.hsluv,
|
||||
"hsluva": color.hsluva,
|
||||
"hsl": color.hsl,
|
||||
"hsla": color.hsla,
|
||||
"rgb": color.rgb,
|
||||
"rgba": color.rgba,
|
||||
}
|
||||
|
||||
|
||||
class GlobalsDict(UserDict):
|
||||
def __init__(self, section: "Section") -> None:
|
||||
super().__init__()
|
||||
self.section = section
|
||||
def __init__(self, section: "Section") -> None:
|
||||
super().__init__()
|
||||
self.section = section
|
||||
|
||||
@property
|
||||
def full_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
**PCN_GLOBALS,
|
||||
**(self.section.root if self.section.root else {}),
|
||||
**(self.section.root.globals if self.section.root else {}),
|
||||
"self": self.section,
|
||||
"parent": self.section.parent,
|
||||
"root": self.section.parent,
|
||||
**self.data,
|
||||
}
|
||||
@property
|
||||
def full_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
**PCN_GLOBALS,
|
||||
**(self.section.root if self.section.root else {}),
|
||||
**(self.section.root.globals if self.section.root else {}),
|
||||
"self": self.section,
|
||||
"parent": self.section.parent,
|
||||
"root": self.section.parent,
|
||||
**self.data,
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.full_dict[key]
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.full_dict[key]
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.full_dict)
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self.full_dict)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.full_dict)
|
||||
def __len__(self) -> int:
|
||||
return len(self.full_dict)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return repr(self.full_dict)
|
||||
def __repr__(self) -> str:
|
||||
return repr(self.full_dict)
|
||||
|
||||
@@ -3,50 +3,50 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Type
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .section import Section
|
||||
from .section import Section
|
||||
|
||||
TYPE_PROCESSORS: Dict[str, Callable[[Any], Any]] = {
|
||||
"tuple": lambda v: tuple(v),
|
||||
"set": lambda v: set(v),
|
||||
"tuple": lambda v: tuple(v),
|
||||
"set": lambda v: set(v),
|
||||
}
|
||||
|
||||
|
||||
class Unset:
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Property:
|
||||
name: str = field()
|
||||
annotation: str = field()
|
||||
expression: str = field()
|
||||
section: "Section" = field()
|
||||
value_override: Any = Unset
|
||||
name: str = field()
|
||||
annotation: str = field()
|
||||
expression: str = field()
|
||||
section: "Section" = field()
|
||||
value_override: Any = Unset
|
||||
|
||||
def __get__(self, obj: "Section", objtype: Type["Section"]) -> Any:
|
||||
if not obj:
|
||||
return self
|
||||
def __get__(self, obj: "Section", objtype: Type["Section"]) -> Any:
|
||||
if not obj:
|
||||
return self
|
||||
|
||||
if self.value_override is not Unset:
|
||||
return self.value_override
|
||||
if self.value_override is not Unset:
|
||||
return self.value_override
|
||||
|
||||
env = obj.globals
|
||||
result = eval(self.expression, dict(env), env) # nosec
|
||||
env = obj.globals
|
||||
result = eval(self.expression, dict(env), env) # nosec
|
||||
|
||||
return process_value(self.annotation, result)
|
||||
return process_value(self.annotation, result)
|
||||
|
||||
def __set__(self, obj: "Section", value: Any) -> None:
|
||||
self.value_override = value
|
||||
obj._edited[self.name] = value
|
||||
def __set__(self, obj: "Section", value: Any) -> None:
|
||||
self.value_override = value
|
||||
obj._edited[self.name] = value
|
||||
|
||||
|
||||
def process_value(annotation: str, value: Any) -> Any:
|
||||
annotation = re.sub(r"\[.*\]$", "", annotation)
|
||||
annotation = re.sub(r"\[.*\]$", "", annotation)
|
||||
|
||||
if annotation in TYPE_PROCESSORS:
|
||||
return TYPE_PROCESSORS[annotation](value)
|
||||
if annotation in TYPE_PROCESSORS:
|
||||
return TYPE_PROCESSORS[annotation](value)
|
||||
|
||||
if annotation.lower() in TYPE_PROCESSORS:
|
||||
return TYPE_PROCESSORS[annotation.lower()](value)
|
||||
if annotation.lower() in TYPE_PROCESSORS:
|
||||
return TYPE_PROCESSORS[annotation.lower()](value)
|
||||
|
||||
return value
|
||||
return value
|
||||
|
||||
@@ -7,8 +7,8 @@ from dataclasses import dataclass, field
|
||||
from operator import attrgetter
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Any, Callable, ClassVar, Dict, Generator, List, Optional, Set, Tuple, Type,
|
||||
Union,
|
||||
Any, Callable, ClassVar, Dict, Generator, List, Optional, Set, Tuple, Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
import pyotherside
|
||||
@@ -25,423 +25,423 @@ assert BUILTINS_DIR.name == "src"
|
||||
|
||||
@dataclass(repr=False, eq=False)
|
||||
class Section(MutableMapping):
|
||||
sections: ClassVar[Set[str]] = set()
|
||||
methods: ClassVar[Set[str]] = set()
|
||||
properties: ClassVar[Set[str]] = set()
|
||||
order: ClassVar[Dict[str, None]] = OrderedDict()
|
||||
sections: ClassVar[Set[str]] = set()
|
||||
methods: ClassVar[Set[str]] = set()
|
||||
properties: ClassVar[Set[str]] = set()
|
||||
order: ClassVar[Dict[str, None]] = OrderedDict()
|
||||
|
||||
source_path: Optional[Path] = None
|
||||
root: Optional["Section"] = None
|
||||
parent: Optional["Section"] = None
|
||||
builtins_path: Path = BUILTINS_DIR
|
||||
included: List[Path] = field(default_factory=list)
|
||||
globals: GlobalsDict = field(init=False)
|
||||
source_path: Optional[Path] = None
|
||||
root: Optional["Section"] = None
|
||||
parent: Optional["Section"] = None
|
||||
builtins_path: Path = BUILTINS_DIR
|
||||
included: List[Path] = field(default_factory=list)
|
||||
globals: GlobalsDict = field(init=False)
|
||||
|
||||
_edited: Dict[str, Any] = field(init=False, default_factory=dict)
|
||||
_edited: Dict[str, Any] = field(init=False, default_factory=dict)
|
||||
|
||||
def __init_subclass__(cls, **kwargs) -> None:
|
||||
# Make these attributes not shared between Section and its subclasses
|
||||
cls.sections = set()
|
||||
cls.methods = set()
|
||||
cls.properties = set()
|
||||
cls.order = OrderedDict()
|
||||
def __init_subclass__(cls, **kwargs) -> None:
|
||||
# Make these attributes not shared between Section and its subclasses
|
||||
cls.sections = set()
|
||||
cls.methods = set()
|
||||
cls.properties = set()
|
||||
cls.order = OrderedDict()
|
||||
|
||||
for parent_class in cls.__bases__:
|
||||
if not issubclass(parent_class, Section):
|
||||
continue
|
||||
for parent_class in cls.__bases__:
|
||||
if not issubclass(parent_class, Section):
|
||||
continue
|
||||
|
||||
cls.sections |= parent_class.sections # union operator
|
||||
cls.methods |= parent_class.methods
|
||||
cls.properties |= parent_class.properties
|
||||
cls.order.update(parent_class.order)
|
||||
cls.sections |= parent_class.sections # union operator
|
||||
cls.methods |= parent_class.methods
|
||||
cls.properties |= parent_class.properties
|
||||
cls.order.update(parent_class.order)
|
||||
|
||||
super().__init_subclass__(**kwargs) # type: ignore
|
||||
super().__init_subclass__(**kwargs) # type: ignore
|
||||
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.globals = GlobalsDict(self)
|
||||
def __post_init__(self) -> None:
|
||||
self.globals = GlobalsDict(self)
|
||||
|
||||
|
||||
def __getattr__(self, name: str) -> Union["Section", Any]:
|
||||
# This method signature tells mypy about the dynamic attribute types
|
||||
# we can access. The body is run for attributes that aren't found.
|
||||
def __getattr__(self, name: str) -> Union["Section", Any]:
|
||||
# This method signature tells mypy about the dynamic attribute types
|
||||
# we can access. The body is run for attributes that aren't found.
|
||||
|
||||
return super().__getattribute__(name)
|
||||
return super().__getattribute__(name)
|
||||
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
# This method tells mypy about the dynamic attribute types we can set.
|
||||
# The body is also run when setting an existing or new attribute.
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
# This method tells mypy about the dynamic attribute types we can set.
|
||||
# The body is also run when setting an existing or new attribute.
|
||||
|
||||
if name in self.__dataclass_fields__:
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
if name in self.__dataclass_fields__:
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
if name in self.properties:
|
||||
value = process_value(getattr(type(self), name).annotation, value)
|
||||
if name in self.properties:
|
||||
value = process_value(getattr(type(self), name).annotation, value)
|
||||
|
||||
if self[name] == value:
|
||||
return
|
||||
if self[name] == value:
|
||||
return
|
||||
|
||||
getattr(type(self), name).value_override = value
|
||||
self._edited[name] = value
|
||||
return
|
||||
getattr(type(self), name).value_override = value
|
||||
self._edited[name] = value
|
||||
return
|
||||
|
||||
if name in self.sections or isinstance(value, Section):
|
||||
raise NotImplementedError(f"cannot set section {name!r}")
|
||||
if name in self.sections or isinstance(value, Section):
|
||||
raise NotImplementedError(f"cannot set section {name!r}")
|
||||
|
||||
if name in self.methods or callable(value):
|
||||
raise NotImplementedError(f"cannot set method {name!r}")
|
||||
if name in self.methods or callable(value):
|
||||
raise NotImplementedError(f"cannot set method {name!r}")
|
||||
|
||||
self._set_property(name, "Any", "None")
|
||||
getattr(type(self), name).value_override = value
|
||||
self._edited[name] = value
|
||||
self._set_property(name, "Any", "None")
|
||||
getattr(type(self), name).value_override = value
|
||||
self._edited[name] = value
|
||||
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise NotImplementedError(f"cannot delete existing attribute {name!r}")
|
||||
def __delattr__(self, name: str) -> None:
|
||||
raise NotImplementedError(f"cannot delete existing attribute {name!r}")
|
||||
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except AttributeError as err:
|
||||
raise KeyError(str(err))
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except AttributeError as err:
|
||||
raise KeyError(str(err))
|
||||
|
||||
|
||||
def __setitem__(self, key: str, value: Union["Section", str]) -> None:
|
||||
setattr(self, key, value)
|
||||
def __setitem__(self, key: str, value: Union["Section", str]) -> None:
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
delattr(self, key)
|
||||
def __delitem__(self, key: str) -> None:
|
||||
delattr(self, key)
|
||||
|
||||
|
||||
def __iter__(self) -> Generator[str, None, None]:
|
||||
for attr_name in self.order:
|
||||
yield attr_name
|
||||
def __iter__(self) -> Generator[str, None, None]:
|
||||
for attr_name in self.order:
|
||||
yield attr_name
|
||||
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.order)
|
||||
def __len__(self) -> int:
|
||||
return len(self.order)
|
||||
|
||||
|
||||
def __eq__(self, obj: Any) -> bool:
|
||||
if not isinstance(obj, Section):
|
||||
return False
|
||||
def __eq__(self, obj: Any) -> bool:
|
||||
if not isinstance(obj, Section):
|
||||
return False
|
||||
|
||||
if self.globals.data != obj.globals.data or self.order != obj.order:
|
||||
return False
|
||||
if self.globals.data != obj.globals.data or self.order != obj.order:
|
||||
return False
|
||||
|
||||
return not any(self[attr] != obj[attr] for attr in self.order)
|
||||
return not any(self[attr] != obj[attr] for attr in self.order)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
name: str = type(self).__name__
|
||||
children: List[str] = []
|
||||
content: str = ""
|
||||
newline: bool = False
|
||||
def __repr__(self) -> str:
|
||||
name: str = type(self).__name__
|
||||
children: List[str] = []
|
||||
content: str = ""
|
||||
newline: bool = False
|
||||
|
||||
for attr_name in self.order:
|
||||
value = getattr(self, attr_name)
|
||||
for attr_name in self.order:
|
||||
value = getattr(self, attr_name)
|
||||
|
||||
if attr_name in self.sections:
|
||||
before = "\n" if children else ""
|
||||
newline = True
|
||||
if attr_name in self.sections:
|
||||
before = "\n" if children else ""
|
||||
newline = True
|
||||
|
||||
try:
|
||||
children.append(f"{before}{value!r},")
|
||||
except RecursionError as err:
|
||||
name = type(value).__name__
|
||||
children.append(f"{before}{name}(\n {err!r}\n),")
|
||||
pass
|
||||
try:
|
||||
children.append(f"{before}{value!r},")
|
||||
except RecursionError as err:
|
||||
name = type(value).__name__
|
||||
children.append(f"{before}{name}(\n {err!r}\n),")
|
||||
pass
|
||||
|
||||
elif attr_name in self.methods:
|
||||
before = "\n" if children else ""
|
||||
newline = True
|
||||
children.append(f"{before}def {value.__name__}(…),")
|
||||
elif attr_name in self.methods:
|
||||
before = "\n" if children else ""
|
||||
newline = True
|
||||
children.append(f"{before}def {value.__name__}(…),")
|
||||
|
||||
elif attr_name in self.properties:
|
||||
before = "\n" if newline else ""
|
||||
newline = False
|
||||
elif attr_name in self.properties:
|
||||
before = "\n" if newline else ""
|
||||
newline = False
|
||||
|
||||
try:
|
||||
children.append(f"{before}{attr_name} = {value!r},")
|
||||
except RecursionError as err:
|
||||
children.append(f"{before}{attr_name} = {err!r},")
|
||||
try:
|
||||
children.append(f"{before}{attr_name} = {value!r},")
|
||||
except RecursionError as err:
|
||||
children.append(f"{before}{attr_name} = {err!r},")
|
||||
|
||||
else:
|
||||
newline = False
|
||||
else:
|
||||
newline = False
|
||||
|
||||
if children:
|
||||
content = "\n%s\n" % textwrap.indent("\n".join(children), " " * 4)
|
||||
if children:
|
||||
content = "\n%s\n" % textwrap.indent("\n".join(children), " " * 4)
|
||||
|
||||
return f"{name}({content})"
|
||||
return f"{name}({content})"
|
||||
|
||||
|
||||
def children(self) -> Tuple[Tuple[str, Union["Section", Any]], ...]:
|
||||
"""Return pairs of (name, value) for child sections and properties."""
|
||||
return tuple((name, getattr(self, name)) for name in self)
|
||||
def children(self) -> Tuple[Tuple[str, Union["Section", Any]], ...]:
|
||||
"""Return pairs of (name, value) for child sections and properties."""
|
||||
return tuple((name, getattr(self, name)) for name in self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _register_set_attr(cls, name: str, add_to_set_name: str) -> None:
|
||||
cls.methods.discard(name)
|
||||
cls.properties.discard(name)
|
||||
cls.sections.discard(name)
|
||||
getattr(cls, add_to_set_name).add(name)
|
||||
cls.order[name] = None
|
||||
@classmethod
|
||||
def _register_set_attr(cls, name: str, add_to_set_name: str) -> None:
|
||||
cls.methods.discard(name)
|
||||
cls.properties.discard(name)
|
||||
cls.sections.discard(name)
|
||||
getattr(cls, add_to_set_name).add(name)
|
||||
cls.order[name] = None
|
||||
|
||||
for subclass in cls.__subclasses__():
|
||||
subclass._register_set_attr(name, add_to_set_name)
|
||||
for subclass in cls.__subclasses__():
|
||||
subclass._register_set_attr(name, add_to_set_name)
|
||||
|
||||
|
||||
def _set_section(self, section: "Section") -> None:
|
||||
name = type(section).__name__
|
||||
def _set_section(self, section: "Section") -> None:
|
||||
name = type(section).__name__
|
||||
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
|
||||
if name in self.sections:
|
||||
self[name].deep_merge(section)
|
||||
return
|
||||
if name in self.sections:
|
||||
self[name].deep_merge(section)
|
||||
return
|
||||
|
||||
self._register_set_attr(name, "sections")
|
||||
setattr(type(self), name, section)
|
||||
self._register_set_attr(name, "sections")
|
||||
setattr(type(self), name, section)
|
||||
|
||||
|
||||
def _set_method(self, name: str, method: Callable) -> None:
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
def _set_method(self, name: str, method: Callable) -> None:
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
|
||||
self._register_set_attr(name, "methods")
|
||||
setattr(type(self), name, method)
|
||||
self._register_set_attr(name, "methods")
|
||||
setattr(type(self), name, method)
|
||||
|
||||
|
||||
def _set_property(
|
||||
self, name: str, annotation: str, expression: str,
|
||||
) -> None:
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
def _set_property(
|
||||
self, name: str, annotation: str, expression: str,
|
||||
) -> None:
|
||||
if hasattr(self, name) and name not in self.order:
|
||||
raise AttributeError(f"{name!r}: forbidden name")
|
||||
|
||||
prop = Property(name, annotation, expression, self)
|
||||
self._register_set_attr(name, "properties")
|
||||
setattr(type(self), name, prop)
|
||||
prop = Property(name, annotation, expression, self)
|
||||
self._register_set_attr(name, "properties")
|
||||
setattr(type(self), name, prop)
|
||||
|
||||
|
||||
def deep_merge(self, section2: "Section") -> None:
|
||||
self.included += section2.included
|
||||
def deep_merge(self, section2: "Section") -> None:
|
||||
self.included += section2.included
|
||||
|
||||
for key in section2:
|
||||
if key in self.sections and key in section2.sections:
|
||||
self.globals.data.update(section2.globals.data)
|
||||
self[key].deep_merge(section2[key])
|
||||
for key in section2:
|
||||
if key in self.sections and key in section2.sections:
|
||||
self.globals.data.update(section2.globals.data)
|
||||
self[key].deep_merge(section2[key])
|
||||
|
||||
elif key in section2.sections:
|
||||
self.globals.data.update(section2.globals.data)
|
||||
new_type = type(key, (Section,), {})
|
||||
instance = new_type(
|
||||
source_path = self.source_path,
|
||||
root = self.root or self,
|
||||
parent = self,
|
||||
builtins_path = self.builtins_path,
|
||||
)
|
||||
self._set_section(instance)
|
||||
instance.deep_merge(section2[key])
|
||||
elif key in section2.sections:
|
||||
self.globals.data.update(section2.globals.data)
|
||||
new_type = type(key, (Section,), {})
|
||||
instance = new_type(
|
||||
source_path = self.source_path,
|
||||
root = self.root or self,
|
||||
parent = self,
|
||||
builtins_path = self.builtins_path,
|
||||
)
|
||||
self._set_section(instance)
|
||||
instance.deep_merge(section2[key])
|
||||
|
||||
elif key in section2.methods:
|
||||
self._set_method(key, section2[key])
|
||||
elif key in section2.methods:
|
||||
self._set_method(key, section2[key])
|
||||
|
||||
else:
|
||||
prop2 = getattr(type(section2), key)
|
||||
self._set_property(key, prop2.annotation, prop2.expression)
|
||||
else:
|
||||
prop2 = getattr(type(section2), key)
|
||||
self._set_property(key, prop2.annotation, prop2.expression)
|
||||
|
||||
|
||||
def include_file(self, path: Union[Path, str]) -> None:
|
||||
path = Path(path)
|
||||
def include_file(self, path: Union[Path, str]) -> None:
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_absolute() and self.source_path:
|
||||
path = self.source_path.parent / path
|
||||
if not path.is_absolute() and self.source_path:
|
||||
path = self.source_path.parent / path
|
||||
|
||||
with suppress(ValueError):
|
||||
self.included.remove(path)
|
||||
with suppress(ValueError):
|
||||
self.included.remove(path)
|
||||
|
||||
self.included.append(path)
|
||||
self.deep_merge(Section.from_file(path))
|
||||
self.included.append(path)
|
||||
self.deep_merge(Section.from_file(path))
|
||||
|
||||
|
||||
def include_builtin(self, relative_path: Union[Path, str]) -> None:
|
||||
path = self.builtins_path / relative_path
|
||||
def include_builtin(self, relative_path: Union[Path, str]) -> None:
|
||||
path = self.builtins_path / relative_path
|
||||
|
||||
with suppress(ValueError):
|
||||
self.included.remove(path)
|
||||
with suppress(ValueError):
|
||||
self.included.remove(path)
|
||||
|
||||
self.included.append(path)
|
||||
self.deep_merge(Section.from_file(path))
|
||||
self.included.append(path)
|
||||
self.deep_merge(Section.from_file(path))
|
||||
|
||||
|
||||
def as_dict(self, _section: Optional["Section"] = None) -> Dict[str, Any]:
|
||||
dct = {}
|
||||
section = self if _section is None else _section
|
||||
def as_dict(self, _section: Optional["Section"] = None) -> Dict[str, Any]:
|
||||
dct = {}
|
||||
section = self if _section is None else _section
|
||||
|
||||
for key, value in section.items():
|
||||
if isinstance(value, Section):
|
||||
dct[key] = self.as_dict(value)
|
||||
else:
|
||||
dct[key] = value
|
||||
for key, value in section.items():
|
||||
if isinstance(value, Section):
|
||||
dct[key] = self.as_dict(value)
|
||||
else:
|
||||
dct[key] = value
|
||||
|
||||
return dct
|
||||
return dct
|
||||
|
||||
|
||||
def edits_as_dict(
|
||||
self, _section: Optional["Section"] = None,
|
||||
) -> Dict[str, Any]:
|
||||
def edits_as_dict(
|
||||
self, _section: Optional["Section"] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
warning = (
|
||||
"This file is generated when settings are changed from the GUI, "
|
||||
"and properties in it override the ones in the corresponding "
|
||||
"PCN user config file. "
|
||||
"If a property is gets changed in the PCN file, any corresponding "
|
||||
"property override here is removed."
|
||||
)
|
||||
warning = (
|
||||
"This file is generated when settings are changed from the GUI, "
|
||||
"and properties in it override the ones in the corresponding "
|
||||
"PCN user config file. "
|
||||
"If a property is gets changed in the PCN file, any corresponding "
|
||||
"property override here is removed."
|
||||
)
|
||||
|
||||
if _section is None:
|
||||
section = self
|
||||
dct = {"__comment": warning, "set": section._edited.copy()}
|
||||
add_to = dct["set"]
|
||||
else:
|
||||
section = _section
|
||||
dct = {
|
||||
prop_name: (
|
||||
getattr(type(section), prop_name).expression,
|
||||
value_override,
|
||||
)
|
||||
for prop_name, value_override in section._edited.items()
|
||||
}
|
||||
add_to = dct
|
||||
|
||||
for name in section.sections:
|
||||
edits = section.edits_as_dict(section[name])
|
||||
|
||||
if edits:
|
||||
add_to[name] = edits # type: ignore
|
||||
|
||||
return dct
|
||||
|
||||
|
||||
def deep_merge_edits(
|
||||
self, edits: Dict[str, Any], has_expressions: bool = True,
|
||||
) -> bool:
|
||||
|
||||
changes = False
|
||||
|
||||
if not self.parent: # this is Root
|
||||
edits = edits.get("set", {})
|
||||
|
||||
for name, value in edits.copy().items():
|
||||
if isinstance(self.get(name), Section) and isinstance(value, dict):
|
||||
if self[name].deep_merge_edits(value, has_expressions):
|
||||
changes = True
|
||||
if _section is None:
|
||||
section = self
|
||||
dct = {"__comment": warning, "set": section._edited.copy()}
|
||||
add_to = dct["set"]
|
||||
else:
|
||||
section = _section
|
||||
dct = {
|
||||
prop_name: (
|
||||
getattr(type(section), prop_name).expression,
|
||||
value_override,
|
||||
)
|
||||
for prop_name, value_override in section._edited.items()
|
||||
}
|
||||
add_to = dct
|
||||
|
||||
for name in section.sections:
|
||||
edits = section.edits_as_dict(section[name])
|
||||
|
||||
if edits:
|
||||
add_to[name] = edits # type: ignore
|
||||
|
||||
return dct
|
||||
|
||||
|
||||
def deep_merge_edits(
|
||||
self, edits: Dict[str, Any], has_expressions: bool = True,
|
||||
) -> bool:
|
||||
|
||||
changes = False
|
||||
|
||||
if not self.parent: # this is Root
|
||||
edits = edits.get("set", {})
|
||||
|
||||
for name, value in edits.copy().items():
|
||||
if isinstance(self.get(name), Section) and isinstance(value, dict):
|
||||
if self[name].deep_merge_edits(value, has_expressions):
|
||||
changes = True
|
||||
|
||||
elif not has_expressions:
|
||||
self[name] = value
|
||||
elif not has_expressions:
|
||||
self[name] = value
|
||||
|
||||
elif isinstance(value, (tuple, list)):
|
||||
user_expression, gui_value = value
|
||||
elif isinstance(value, (tuple, list)):
|
||||
user_expression, gui_value = value
|
||||
|
||||
if not hasattr(type(self), name):
|
||||
self[name] = gui_value
|
||||
elif getattr(type(self), name).expression == user_expression:
|
||||
self[name] = gui_value
|
||||
else:
|
||||
# If user changed their config file, discard the GUI edit
|
||||
del edits[name]
|
||||
changes = True
|
||||
if not hasattr(type(self), name):
|
||||
self[name] = gui_value
|
||||
elif getattr(type(self), name).expression == user_expression:
|
||||
self[name] = gui_value
|
||||
else:
|
||||
# If user changed their config file, discard the GUI edit
|
||||
del edits[name]
|
||||
changes = True
|
||||
|
||||
return changes
|
||||
return changes
|
||||
|
||||
@property
|
||||
def all_includes(self) -> Generator[Path, None, None]:
|
||||
@property
|
||||
def all_includes(self) -> Generator[Path, None, None]:
|
||||
|
||||
yield from self.included
|
||||
yield from self.included
|
||||
|
||||
for sub in self.sections:
|
||||
yield from self[sub].all_includes
|
||||
for sub in self.sections:
|
||||
yield from self[sub].all_includes
|
||||
|
||||
@classmethod
|
||||
def from_source_code(
|
||||
cls,
|
||||
code: str,
|
||||
path: Optional[Path] = None,
|
||||
builtins: Optional[Path] = None,
|
||||
*,
|
||||
inherit: Tuple[Type["Section"], ...] = (),
|
||||
node: Union[None, red.RedBaron, red.ClassNode] = None,
|
||||
name: str = "Root",
|
||||
root: Optional["Section"] = None,
|
||||
parent: Optional["Section"] = None,
|
||||
) -> "Section":
|
||||
|
||||
builtins = builtins or BUILTINS_DIR
|
||||
section: Type["Section"] = type(name, inherit or (Section,), {})
|
||||
instance: Section = section(path, root, parent, builtins)
|
||||
|
||||
node = node or red.RedBaron(code)
|
||||
|
||||
for child in node.node_list:
|
||||
if isinstance(child, red.ClassNode):
|
||||
root_arg = instance if root is None else root
|
||||
child_inherit = []
|
||||
|
||||
for name in child.inherit_from.dumps().split(","):
|
||||
name = name.strip()
|
||||
|
||||
if name:
|
||||
child_inherit.append(type(attrgetter(name)(root_arg)))
|
||||
|
||||
instance._set_section(section.from_source_code(
|
||||
code = code,
|
||||
path = path,
|
||||
builtins = builtins,
|
||||
inherit = tuple(child_inherit),
|
||||
node = child,
|
||||
name = child.name,
|
||||
root = root_arg,
|
||||
parent = instance,
|
||||
))
|
||||
|
||||
elif isinstance(child, red.AssignmentNode):
|
||||
if isinstance(child.target, red.NameNode):
|
||||
name = child.target.value
|
||||
else:
|
||||
name = str(child.target.to_python())
|
||||
@classmethod
|
||||
def from_source_code(
|
||||
cls,
|
||||
code: str,
|
||||
path: Optional[Path] = None,
|
||||
builtins: Optional[Path] = None,
|
||||
*,
|
||||
inherit: Tuple[Type["Section"], ...] = (),
|
||||
node: Union[None, red.RedBaron, red.ClassNode] = None,
|
||||
name: str = "Root",
|
||||
root: Optional["Section"] = None,
|
||||
parent: Optional["Section"] = None,
|
||||
) -> "Section":
|
||||
|
||||
builtins = builtins or BUILTINS_DIR
|
||||
section: Type["Section"] = type(name, inherit or (Section,), {})
|
||||
instance: Section = section(path, root, parent, builtins)
|
||||
|
||||
node = node or red.RedBaron(code)
|
||||
|
||||
for child in node.node_list:
|
||||
if isinstance(child, red.ClassNode):
|
||||
root_arg = instance if root is None else root
|
||||
child_inherit = []
|
||||
|
||||
for name in child.inherit_from.dumps().split(","):
|
||||
name = name.strip()
|
||||
|
||||
if name:
|
||||
child_inherit.append(type(attrgetter(name)(root_arg)))
|
||||
|
||||
instance._set_section(section.from_source_code(
|
||||
code = code,
|
||||
path = path,
|
||||
builtins = builtins,
|
||||
inherit = tuple(child_inherit),
|
||||
node = child,
|
||||
name = child.name,
|
||||
root = root_arg,
|
||||
parent = instance,
|
||||
))
|
||||
|
||||
elif isinstance(child, red.AssignmentNode):
|
||||
if isinstance(child.target, red.NameNode):
|
||||
name = child.target.value
|
||||
else:
|
||||
name = str(child.target.to_python())
|
||||
|
||||
instance._set_property(
|
||||
name,
|
||||
child.annotation.dumps() if child.annotation else "",
|
||||
child.value.dumps(),
|
||||
)
|
||||
instance._set_property(
|
||||
name,
|
||||
child.annotation.dumps() if child.annotation else "",
|
||||
child.value.dumps(),
|
||||
)
|
||||
|
||||
else:
|
||||
env = instance.globals
|
||||
exec(child.dumps(), dict(env), env) # nosec
|
||||
else:
|
||||
env = instance.globals
|
||||
exec(child.dumps(), dict(env), env) # nosec
|
||||
|
||||
if isinstance(child, red.DefNode):
|
||||
instance._set_method(child.name, env[child.name])
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls, path: Union[str, Path], builtins: Union[str, Path] = BUILTINS_DIR,
|
||||
) -> "Section":
|
||||
|
||||
path = Path(re.sub(r"^qrc:/", "", str(path)))
|
||||
|
||||
try:
|
||||
content = pyotherside.qrc_get_file_contents(str(path)).decode()
|
||||
except ValueError: # App was compiled without QRC
|
||||
content = path.read_text()
|
||||
|
||||
return Section.from_source_code(content, path, Path(builtins))
|
||||
if isinstance(child, red.DefNode):
|
||||
instance._set_method(child.name, env[child.name])
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls, path: Union[str, Path], builtins: Union[str, Path] = BUILTINS_DIR,
|
||||
) -> "Section":
|
||||
|
||||
path = Path(re.sub(r"^qrc:/", "", str(path)))
|
||||
|
||||
try:
|
||||
content = pyotherside.qrc_get_file_contents(str(path)).decode()
|
||||
except ValueError: # App was compiled without QRC
|
||||
content = path.read_text()
|
||||
|
||||
return Section.from_source_code(content, path, Path(builtins))
|
||||
|
||||
@@ -8,89 +8,89 @@ from typing import TYPE_CHECKING, Dict, Optional
|
||||
from .utils import AutoStrEnum, auto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models.items import Account, Member
|
||||
from .models.items import Account, Member
|
||||
|
||||
ORDER: Dict[str, int] = {
|
||||
"online": 0,
|
||||
"unavailable": 1,
|
||||
"invisible": 2,
|
||||
"offline": 3,
|
||||
"online": 0,
|
||||
"unavailable": 1,
|
||||
"invisible": 2,
|
||||
"offline": 3,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Presence:
|
||||
"""Represents a single matrix user's presence fields.
|
||||
"""Represents a single matrix user's presence fields.
|
||||
|
||||
These objects are stored in `Backend.presences`, indexed by user ID.
|
||||
It must only be instanced when receiving a `PresenceEvent` or
|
||||
registering an `Account` model item.
|
||||
These objects are stored in `Backend.presences`, indexed by user ID.
|
||||
It must only be instanced when receiving a `PresenceEvent` or
|
||||
registering an `Account` model item.
|
||||
|
||||
When receiving a `PresenceEvent`, we get or create a `Presence` object in
|
||||
`Backend.presences` for the targeted user. If the user is registered in any
|
||||
room, add its `Member` model item to `members`. Finally, update every
|
||||
`Member` presence fields inside `members`.
|
||||
When receiving a `PresenceEvent`, we get or create a `Presence` object in
|
||||
`Backend.presences` for the targeted user. If the user is registered in any
|
||||
room, add its `Member` model item to `members`. Finally, update every
|
||||
`Member` presence fields inside `members`.
|
||||
|
||||
When a room member is registered, we try to find a `Presence` in
|
||||
`Backend.presences` for that user ID. If found, the `Member` item is added
|
||||
to `members`.
|
||||
When a room member is registered, we try to find a `Presence` in
|
||||
`Backend.presences` for that user ID. If found, the `Member` item is added
|
||||
to `members`.
|
||||
|
||||
When an Account model is registered, we create a `Presence` in
|
||||
`Backend.presences` for the accountu's user ID whether the server supports
|
||||
presence or not (we cannot know yet at this point),
|
||||
and assign that `Account` to the `Presence.account` field.
|
||||
When an Account model is registered, we create a `Presence` in
|
||||
`Backend.presences` for the accountu's user ID whether the server supports
|
||||
presence or not (we cannot know yet at this point),
|
||||
and assign that `Account` to the `Presence.account` field.
|
||||
|
||||
Special attributes:
|
||||
members: A `{room_id: Member}` dict for storing room members related to
|
||||
this `Presence`. As each room has its own `Member`s objects, we
|
||||
have to keep track of their presence fields. `Member`s are indexed
|
||||
by room ID.
|
||||
Special attributes:
|
||||
members: A `{room_id: Member}` dict for storing room members related to
|
||||
this `Presence`. As each room has its own `Member`s objects, we
|
||||
have to keep track of their presence fields. `Member`s are indexed
|
||||
by room ID.
|
||||
|
||||
account: `Account` related to this `Presence`, if any. Should be
|
||||
assigned when client starts (`MatrixClient._start()`) and
|
||||
cleared when client stops (`MatrixClient._start()`).
|
||||
"""
|
||||
account: `Account` related to this `Presence`, if any. Should be
|
||||
assigned when client starts (`MatrixClient._start()`) and
|
||||
cleared when client stops (`MatrixClient._start()`).
|
||||
"""
|
||||
|
||||
class State(AutoStrEnum):
|
||||
offline = auto() # can mean offline, invisible or unknwon
|
||||
unavailable = auto()
|
||||
online = auto()
|
||||
invisible = auto()
|
||||
class State(AutoStrEnum):
|
||||
offline = auto() # can mean offline, invisible or unknwon
|
||||
unavailable = auto()
|
||||
online = auto()
|
||||
invisible = auto()
|
||||
|
||||
def __lt__(self, other: "Presence.State") -> bool:
|
||||
return ORDER[self.value] < ORDER[other.value]
|
||||
def __lt__(self, other: "Presence.State") -> bool:
|
||||
return ORDER[self.value] < ORDER[other.value]
|
||||
|
||||
presence: State = State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = datetime.fromtimestamp(0)
|
||||
status_msg: str = ""
|
||||
presence: State = State.offline
|
||||
currently_active: bool = False
|
||||
last_active_at: datetime = datetime.fromtimestamp(0)
|
||||
status_msg: str = ""
|
||||
|
||||
members: Dict[str, "Member"] = field(default_factory=dict)
|
||||
account: Optional["Account"] = None
|
||||
members: Dict[str, "Member"] = field(default_factory=dict)
|
||||
account: Optional["Account"] = None
|
||||
|
||||
|
||||
def update_members(self) -> None:
|
||||
"""Update presence fields of every `Member` in `members`.
|
||||
def update_members(self) -> None:
|
||||
"""Update presence fields of every `Member` in `members`.
|
||||
|
||||
Currently it is only called when receiving a `PresenceEvent` and when
|
||||
registering room members.
|
||||
"""
|
||||
Currently it is only called when receiving a `PresenceEvent` and when
|
||||
registering room members.
|
||||
"""
|
||||
|
||||
for member in self.members.values():
|
||||
member.set_fields(
|
||||
presence = self.presence,
|
||||
status_msg = self.status_msg,
|
||||
last_active_at = self.last_active_at,
|
||||
currently_active = self.currently_active,
|
||||
)
|
||||
for member in self.members.values():
|
||||
member.set_fields(
|
||||
presence = self.presence,
|
||||
status_msg = self.status_msg,
|
||||
last_active_at = self.last_active_at,
|
||||
currently_active = self.currently_active,
|
||||
)
|
||||
|
||||
def update_account(self) -> None:
|
||||
"""Update presence fields of `Account` related to this `Presence`."""
|
||||
def update_account(self) -> None:
|
||||
"""Update presence fields of `Account` related to this `Presence`."""
|
||||
|
||||
if self.account:
|
||||
self.account.set_fields(
|
||||
presence = self.presence,
|
||||
status_msg = self.status_msg,
|
||||
last_active_at = self.last_active_at,
|
||||
currently_active = self.currently_active,
|
||||
)
|
||||
if self.account:
|
||||
self.account.set_fields(
|
||||
presence = self.presence,
|
||||
status_msg = self.status_msg,
|
||||
last_active_at = self.last_active_at,
|
||||
currently_active = self.currently_active,
|
||||
)
|
||||
|
||||
@@ -10,117 +10,117 @@ import pyotherside
|
||||
from .utils import serialize_value_for_qml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import SyncId
|
||||
from .user_files import UserFile
|
||||
from .models import SyncId
|
||||
from .user_files import UserFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class PyOtherSideEvent:
|
||||
"""Event that will be sent on instanciation to QML by PyOtherSide."""
|
||||
"""Event that will be sent on instanciation to QML by PyOtherSide."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# XXX: CPython 3.6 or any Python implemention >= 3.7 is required for
|
||||
# correct __dataclass_fields__ dict order.
|
||||
args = [
|
||||
serialize_value_for_qml(getattr(self, field))
|
||||
for field in self.__dataclass_fields__ # type: ignore
|
||||
if field != "callbacks"
|
||||
]
|
||||
pyotherside.send(type(self).__name__, *args)
|
||||
def __post_init__(self) -> None:
|
||||
# XXX: CPython 3.6 or any Python implemention >= 3.7 is required for
|
||||
# correct __dataclass_fields__ dict order.
|
||||
args = [
|
||||
serialize_value_for_qml(getattr(self, field))
|
||||
for field in self.__dataclass_fields__ # type: ignore
|
||||
if field != "callbacks"
|
||||
]
|
||||
pyotherside.send(type(self).__name__, *args)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationRequested(PyOtherSideEvent):
|
||||
"""Request a notification bubble, sound or window urgency hint.
|
||||
"""Request a notification bubble, sound or window urgency hint.
|
||||
|
||||
Urgency hints usually flash or highlight the program's icon in a taskbar,
|
||||
dock or panel.
|
||||
"""
|
||||
Urgency hints usually flash or highlight the program's icon in a taskbar,
|
||||
dock or panel.
|
||||
"""
|
||||
|
||||
id: str = field()
|
||||
critical: bool = False
|
||||
bubble: bool = False
|
||||
sound: bool = False
|
||||
urgency_hint: bool = False
|
||||
id: str = field()
|
||||
critical: bool = False
|
||||
bubble: bool = False
|
||||
sound: bool = False
|
||||
urgency_hint: bool = False
|
||||
|
||||
# Bubble parameters
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
image: Union[Path, str] = ""
|
||||
# Bubble parameters
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
image: Union[Path, str] = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoroutineDone(PyOtherSideEvent):
|
||||
"""Indicate that an asyncio coroutine finished."""
|
||||
"""Indicate that an asyncio coroutine finished."""
|
||||
|
||||
uuid: str = field()
|
||||
result: Any = None
|
||||
exception: Optional[Exception] = None
|
||||
traceback: Optional[str] = None
|
||||
uuid: str = field()
|
||||
result: Any = None
|
||||
exception: Optional[Exception] = None
|
||||
traceback: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoopException(PyOtherSideEvent):
|
||||
"""Indicate an uncaught exception occurance in the asyncio loop."""
|
||||
"""Indicate an uncaught exception occurance in the asyncio loop."""
|
||||
|
||||
message: str = field()
|
||||
exception: Optional[Exception] = field()
|
||||
traceback: Optional[str] = None
|
||||
message: str = field()
|
||||
exception: Optional[Exception] = field()
|
||||
traceback: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pre070SettingsDetected(PyOtherSideEvent):
|
||||
"""Warn that a pre-0.7.0 settings.json file exists."""
|
||||
path: Path = field()
|
||||
"""Warn that a pre-0.7.0 settings.json file exists."""
|
||||
path: Path = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFileChanged(PyOtherSideEvent):
|
||||
"""Indicate that a config or data file changed on disk."""
|
||||
"""Indicate that a config or data file changed on disk."""
|
||||
|
||||
type: Type["UserFile"] = field()
|
||||
new_data: Any = field()
|
||||
type: Type["UserFile"] = field()
|
||||
new_data: Any = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelEvent(PyOtherSideEvent):
|
||||
"""Base class for model change events."""
|
||||
"""Base class for model change events."""
|
||||
|
||||
sync_id: "SyncId" = field()
|
||||
sync_id: "SyncId" = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelItemSet(ModelEvent):
|
||||
"""Indicate `ModelItem` insert or field changes in a `Backend` `Model`."""
|
||||
"""Indicate `ModelItem` insert or field changes in a `Backend` `Model`."""
|
||||
|
||||
index_then: Optional[int] = field()
|
||||
index_now: int = field()
|
||||
fields: Dict[str, Any] = field()
|
||||
index_then: Optional[int] = field()
|
||||
index_now: int = field()
|
||||
fields: Dict[str, Any] = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelItemDeleted(ModelEvent):
|
||||
"""Indicate the removal of a `ModelItem` from a `Backend` `Model`."""
|
||||
"""Indicate the removal of a `ModelItem` from a `Backend` `Model`."""
|
||||
|
||||
index: int = field()
|
||||
count: int = 1
|
||||
ids: Sequence[Any] = ()
|
||||
index: int = field()
|
||||
count: int = 1
|
||||
ids: Sequence[Any] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelCleared(ModelEvent):
|
||||
"""Indicate that a `Backend` `Model` was cleared."""
|
||||
"""Indicate that a `Backend` `Model` was cleared."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DevicesUpdated(PyOtherSideEvent):
|
||||
"""Indicate changes in devices for us or users we share a room with."""
|
||||
"""Indicate changes in devices for us or users we share a room with."""
|
||||
|
||||
our_user_id: str = field()
|
||||
our_user_id: str = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvalidAccessToken(PyOtherSideEvent):
|
||||
"""Indicate one of our account's access token is invalid or revoked."""
|
||||
"""Indicate one of our account's access token is invalid or revoked."""
|
||||
|
||||
user_id: str = field()
|
||||
user_id: str = field()
|
||||
|
||||
@@ -29,143 +29,143 @@ from .pyotherside_events import CoroutineDone, LoopException
|
||||
|
||||
|
||||
class QMLBridge:
|
||||
"""Setup asyncio and provide methods to call coroutines from QML.
|
||||
"""Setup asyncio and provide methods to call coroutines from QML.
|
||||
|
||||
A thread is created to run the asyncio loop in, to ensure all calls from
|
||||
QML return instantly.
|
||||
Synchronous methods are provided for QML to call coroutines using
|
||||
PyOtherSide, which doesn't have this ability out of the box.
|
||||
A thread is created to run the asyncio loop in, to ensure all calls from
|
||||
QML return instantly.
|
||||
Synchronous methods are provided for QML to call coroutines using
|
||||
PyOtherSide, which doesn't have this ability out of the box.
|
||||
|
||||
Attributes:
|
||||
backend: The `backend.Backend` object containing general coroutines
|
||||
for QML and that manages `MatrixClient` objects.
|
||||
"""
|
||||
Attributes:
|
||||
backend: The `backend.Backend` object containing general coroutines
|
||||
for QML and that manages `MatrixClient` objects.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.set_exception_handler(self._loop_exception_handler)
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
self._loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.set_exception_handler(self._loop_exception_handler)
|
||||
|
||||
from .backend import Backend
|
||||
self.backend: Backend = Backend()
|
||||
from .backend import Backend
|
||||
self.backend: Backend = Backend()
|
||||
|
||||
self._running_futures: Dict[str, Future] = {}
|
||||
self._cancelled_early: Set[str] = set()
|
||||
self._running_futures: Dict[str, Future] = {}
|
||||
self._cancelled_early: Set[str] = set()
|
||||
|
||||
Thread(target=self._start_asyncio_loop).start()
|
||||
Thread(target=self._start_asyncio_loop).start()
|
||||
|
||||
|
||||
def _loop_exception_handler(
|
||||
self, loop: asyncio.AbstractEventLoop, context: dict,
|
||||
) -> None:
|
||||
if "exception" in context:
|
||||
err = context["exception"]
|
||||
trace = "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__),
|
||||
)
|
||||
LoopException(context["message"], err, trace)
|
||||
def _loop_exception_handler(
|
||||
self, loop: asyncio.AbstractEventLoop, context: dict,
|
||||
) -> None:
|
||||
if "exception" in context:
|
||||
err = context["exception"]
|
||||
trace = "".join(
|
||||
traceback.format_exception(type(err), err, err.__traceback__),
|
||||
)
|
||||
LoopException(context["message"], err, trace)
|
||||
|
||||
loop.default_exception_handler(context)
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
|
||||
def _start_asyncio_loop(self) -> None:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_forever()
|
||||
def _start_asyncio_loop(self) -> None:
|
||||
asyncio.set_event_loop(self._loop)
|
||||
self._loop.run_forever()
|
||||
|
||||
|
||||
def _call_coro(self, coro: Coroutine, uuid: str) -> None:
|
||||
"""Schedule a coroutine to run in our thread and return a `Future`."""
|
||||
def _call_coro(self, coro: Coroutine, uuid: str) -> None:
|
||||
"""Schedule a coroutine to run in our thread and return a `Future`."""
|
||||
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
return
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
return
|
||||
|
||||
def on_done(future: Future) -> None:
|
||||
"""Send a PyOtherSide event with the coro's result/exception."""
|
||||
result = exception = trace = None
|
||||
def on_done(future: Future) -> None:
|
||||
"""Send a PyOtherSide event with the coro's result/exception."""
|
||||
result = exception = trace = None
|
||||
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception as err: # noqa
|
||||
exception = err
|
||||
trace = traceback.format_exc().rstrip()
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception as err: # noqa
|
||||
exception = err
|
||||
trace = traceback.format_exc().rstrip()
|
||||
|
||||
CoroutineDone(uuid, result, exception, trace)
|
||||
del self._running_futures[uuid]
|
||||
CoroutineDone(uuid, result, exception, trace)
|
||||
del self._running_futures[uuid]
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
self._running_futures[uuid] = future
|
||||
future.add_done_callback(on_done)
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
self._running_futures[uuid] = future
|
||||
future.add_done_callback(on_done)
|
||||
|
||||
|
||||
def call_backend_coro(
|
||||
self, name: str, uuid: str, args: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Schedule a coroutine from the `QMLBridge.backend` object."""
|
||||
def call_backend_coro(
|
||||
self, name: str, uuid: str, args: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Schedule a coroutine from the `QMLBridge.backend` object."""
|
||||
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
else:
|
||||
self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
else:
|
||||
self._call_coro(attrgetter(name)(self.backend)(*args), uuid)
|
||||
|
||||
|
||||
def call_client_coro(
|
||||
self, user_id: str, name: str, uuid: str, args: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Schedule a coroutine from a `QMLBridge.backend.clients` client."""
|
||||
def call_client_coro(
|
||||
self, user_id: str, name: str, uuid: str, args: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Schedule a coroutine from a `QMLBridge.backend.clients` client."""
|
||||
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
else:
|
||||
client = self.backend.clients[user_id]
|
||||
self._call_coro(attrgetter(name)(client)(*args), uuid)
|
||||
if uuid in self._cancelled_early:
|
||||
self._cancelled_early.remove(uuid)
|
||||
else:
|
||||
client = self.backend.clients[user_id]
|
||||
self._call_coro(attrgetter(name)(client)(*args), uuid)
|
||||
|
||||
|
||||
def cancel_coro(self, uuid: str) -> None:
|
||||
"""Cancel a couroutine scheduled by the `QMLBridge` methods."""
|
||||
def cancel_coro(self, uuid: str) -> None:
|
||||
"""Cancel a couroutine scheduled by the `QMLBridge` methods."""
|
||||
|
||||
if uuid in self._running_futures:
|
||||
self._running_futures[uuid].cancel()
|
||||
else:
|
||||
self._cancelled_early.add(uuid)
|
||||
if uuid in self._running_futures:
|
||||
self._running_futures[uuid].cancel()
|
||||
else:
|
||||
self._cancelled_early.add(uuid)
|
||||
|
||||
|
||||
def pdb(self, extra_data: Sequence = (), remote: bool = False) -> None:
|
||||
"""Call the python debugger, defining some conveniance variables."""
|
||||
def pdb(self, extra_data: Sequence = (), remote: bool = False) -> None:
|
||||
"""Call the python debugger, defining some conveniance variables."""
|
||||
|
||||
ad = extra_data # noqa
|
||||
ba = self.backend # noqa
|
||||
mo = self.backend.models # noqa
|
||||
cl = self.backend.clients
|
||||
gcl = lambda user: cl[f"@{user}"] # noqa
|
||||
ad = extra_data # noqa
|
||||
ba = self.backend # noqa
|
||||
mo = self.backend.models # noqa
|
||||
cl = self.backend.clients
|
||||
gcl = lambda user: cl[f"@{user}"] # noqa
|
||||
|
||||
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
|
||||
rc = lambda c: asyncio.run_coroutine_threadsafe(c, self._loop) # noqa
|
||||
|
||||
try:
|
||||
from devtools import debug # noqa
|
||||
d = debug # noqa
|
||||
except ModuleNotFoundError:
|
||||
log.warning("Module python-devtools not found, can't use debug()")
|
||||
try:
|
||||
from devtools import debug # noqa
|
||||
d = debug # noqa
|
||||
except ModuleNotFoundError:
|
||||
log.warning("Module python-devtools not found, can't use debug()")
|
||||
|
||||
if remote:
|
||||
# Run `socat readline tcp:127.0.0.1:4444` in a terminal to connect
|
||||
import remote_pdb
|
||||
remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace()
|
||||
else:
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
if remote:
|
||||
# Run `socat readline tcp:127.0.0.1:4444` in a terminal to connect
|
||||
import remote_pdb
|
||||
remote_pdb.RemotePdb("127.0.0.1", 4444).set_trace()
|
||||
else:
|
||||
import pdb
|
||||
pdb.set_trace()
|
||||
|
||||
|
||||
def exit(self) -> None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.backend.terminate_clients(), self._loop,
|
||||
).result()
|
||||
except Exception as e: # noqa
|
||||
print(e)
|
||||
def exit(self) -> None:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self.backend.terminate_clients(), self._loop,
|
||||
).result()
|
||||
except Exception as e: # noqa
|
||||
print(e)
|
||||
|
||||
|
||||
# The AppImage AppRun script overwrites some environment path variables to
|
||||
@@ -174,8 +174,8 @@ class QMLBridge:
|
||||
# to prevent problems like QML Qt.openUrlExternally() failing because
|
||||
# the external launched program is affected by our AppImage-specific variables.
|
||||
for var in ("LD_LIBRARY_PATH", "PYTHONHOME", "PYTHONUSERBASE"):
|
||||
if f"RESTORE_{var}" in os.environ:
|
||||
os.environ[var] = os.environ[f"RESTORE_{var}"]
|
||||
if f"RESTORE_{var}" in os.environ:
|
||||
os.environ[var] = os.environ[f"RESTORE_{var}"]
|
||||
|
||||
|
||||
BRIDGE = QMLBridge()
|
||||
|
||||
@@ -9,99 +9,99 @@ from . import __display_name__
|
||||
|
||||
_SUCCESS_HTML_PAGE = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>""" + __display_name__ + """</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { background: hsl(0, 0%, 90%); }
|
||||
<head>
|
||||
<title>""" + __display_name__ + """</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { background: hsl(0, 0%, 90%); }
|
||||
|
||||
@keyframes appear {
|
||||
0% { transform: scale(0); }
|
||||
45% { transform: scale(0); }
|
||||
80% { transform: scale(1.6); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
@keyframes appear {
|
||||
0% { transform: scale(0); }
|
||||
45% { transform: scale(0); }
|
||||
80% { transform: scale(1.6); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -45px 0 0 -45px;
|
||||
border-radius: 50%;
|
||||
font-size: 60px;
|
||||
line-height: 90px;
|
||||
text-align: center;
|
||||
background: hsl(203, 51%, 15%);
|
||||
color: hsl(162, 56%, 42%, 1);
|
||||
animation: appear 0.4s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
.circle {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: -45px 0 0 -45px;
|
||||
border-radius: 50%;
|
||||
font-size: 60px;
|
||||
line-height: 90px;
|
||||
text-align: center;
|
||||
background: hsl(203, 51%, 15%);
|
||||
color: hsl(162, 56%, 42%, 1);
|
||||
animation: appear 0.4s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body><div class="circle">✓</div></body>
|
||||
<body><div class="circle">✓</div></body>
|
||||
</html>"""
|
||||
|
||||
|
||||
class _SSORequestHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None:
|
||||
self.server: "SSOServer"
|
||||
def do_GET(self) -> None:
|
||||
self.server: "SSOServer"
|
||||
|
||||
redirect = "%s/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" % (
|
||||
self.server.for_homeserver,
|
||||
quote(self.server.url_to_open),
|
||||
)
|
||||
redirect = "%s/_matrix/client/r0/login/sso/redirect?redirectUrl=%s" % (
|
||||
self.server.for_homeserver,
|
||||
quote(self.server.url_to_open),
|
||||
)
|
||||
|
||||
parameters = parse_qs(urlparse(self.path).query)
|
||||
parameters = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if "loginToken" in parameters:
|
||||
self.server._token = parameters["loginToken"][0]
|
||||
self.send_response(200) # OK
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(_SUCCESS_HTML_PAGE.encode())
|
||||
else:
|
||||
self.send_response(308) # Permanent redirect, same method only
|
||||
self.send_header("Location", redirect)
|
||||
self.end_headers()
|
||||
if "loginToken" in parameters:
|
||||
self.server._token = parameters["loginToken"][0]
|
||||
self.send_response(200) # OK
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(_SUCCESS_HTML_PAGE.encode())
|
||||
else:
|
||||
self.send_response(308) # Permanent redirect, same method only
|
||||
self.send_header("Location", redirect)
|
||||
self.end_headers()
|
||||
|
||||
self.close_connection = True
|
||||
self.close_connection = True
|
||||
|
||||
|
||||
class SSOServer(HTTPServer):
|
||||
"""Local HTTP server to retrieve a SSO login token.
|
||||
"""Local HTTP server to retrieve a SSO login token.
|
||||
|
||||
Call `SSOServer.wait_for_token()` in a background task to start waiting
|
||||
for a SSO login token from the Matrix homeserver.
|
||||
Call `SSOServer.wait_for_token()` in a background task to start waiting
|
||||
for a SSO login token from the Matrix homeserver.
|
||||
|
||||
Once the task is running, the user must open `SSOServer.url_to_open` in
|
||||
their browser, where they will be able to complete the login process.
|
||||
Once they are done, the homeserver will call us back with a login token
|
||||
and the `SSOServer.wait_for_token()` task will return.
|
||||
"""
|
||||
Once the task is running, the user must open `SSOServer.url_to_open` in
|
||||
their browser, where they will be able to complete the login process.
|
||||
Once they are done, the homeserver will call us back with a login token
|
||||
and the `SSOServer.wait_for_token()` task will return.
|
||||
"""
|
||||
|
||||
def __init__(self, for_homeserver: str) -> None:
|
||||
self.for_homeserver: str = for_homeserver
|
||||
self._token: str = ""
|
||||
def __init__(self, for_homeserver: str) -> None:
|
||||
self.for_homeserver: str = for_homeserver
|
||||
self._token: str = ""
|
||||
|
||||
# Pick the first available port
|
||||
super().__init__(("127.0.0.1", 0), _SSORequestHandler)
|
||||
# Pick the first available port
|
||||
super().__init__(("127.0.0.1", 0), _SSORequestHandler)
|
||||
|
||||
|
||||
@property
|
||||
def url_to_open(self) -> str:
|
||||
"""URL for the user to open in their browser, to do the SSO process."""
|
||||
@property
|
||||
def url_to_open(self) -> str:
|
||||
"""URL for the user to open in their browser, to do the SSO process."""
|
||||
|
||||
return f"http://{self.server_address[0]}:{self.server_port}"
|
||||
return f"http://{self.server_address[0]}:{self.server_port}"
|
||||
|
||||
|
||||
async def wait_for_token(self) -> str:
|
||||
"""Wait until the homeserver gives us a login token and return it."""
|
||||
async def wait_for_token(self) -> str:
|
||||
"""Wait until the homeserver gives us a login token and return it."""
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
while not self._token:
|
||||
await loop.run_in_executor(None, self.handle_request)
|
||||
while not self._token:
|
||||
await loop.run_in_executor(None, self.handle_request)
|
||||
|
||||
return self._token
|
||||
return self._token
|
||||
|
||||
@@ -11,77 +11,77 @@ import re
|
||||
from typing import Generator
|
||||
|
||||
PROPERTY_TYPES = {"bool", "double", "int", "list", "real", "string", "url",
|
||||
"var", "date", "point", "rect", "size", "color"}
|
||||
"var", "date", "point", "rect", "size", "color"}
|
||||
|
||||
|
||||
def _add_property(line: str) -> str:
|
||||
"""Return a QML property declaration line from a QPL property line."""
|
||||
"""Return a QML property declaration line from a QPL property line."""
|
||||
|
||||
if re.match(r"^\s*[a-zA-Z\d_]+\s*:$", line):
|
||||
return re.sub(r"^(\s*)(\S*\s*):$",
|
||||
r"\1readonly property QtObject \2: QtObject",
|
||||
line)
|
||||
if re.match(r"^\s*[a-zA-Z\d_]+\s*:$", line):
|
||||
return re.sub(r"^(\s*)(\S*\s*):$",
|
||||
r"\1readonly property QtObject \2: QtObject",
|
||||
line)
|
||||
|
||||
types = "|".join(PROPERTY_TYPES)
|
||||
if re.match(fr"^\s*({types}) [a-zA-Z\d_]+\s*:", line):
|
||||
return re.sub(r"^(\s*)(\S*)", r"\1property \2", line)
|
||||
types = "|".join(PROPERTY_TYPES)
|
||||
if re.match(fr"^\s*({types}) [a-zA-Z\d_]+\s*:", line):
|
||||
return re.sub(r"^(\s*)(\S*)", r"\1property \2", line)
|
||||
|
||||
return line
|
||||
return line
|
||||
|
||||
|
||||
def _process_lines(content: str) -> Generator[str, None, None]:
|
||||
"""Yield lines of real QML from lines of QPL."""
|
||||
"""Yield lines of real QML from lines of QPL."""
|
||||
|
||||
skip = False
|
||||
indent = " " * 4
|
||||
current_indent = 0
|
||||
skip = False
|
||||
indent = " " * 4
|
||||
current_indent = 0
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.rstrip()
|
||||
for line in content.split("\n"):
|
||||
line = line.rstrip()
|
||||
|
||||
if not line.strip() or line.strip().startswith("//"):
|
||||
continue
|
||||
if not line.strip() or line.strip().startswith("//"):
|
||||
continue
|
||||
|
||||
start_space_list = re.findall(r"^ +", line)
|
||||
start_space = start_space_list[0] if start_space_list else ""
|
||||
start_space_list = re.findall(r"^ +", line)
|
||||
start_space = start_space_list[0] if start_space_list else ""
|
||||
|
||||
line_indents = len(re.findall(indent, start_space))
|
||||
line_indents = len(re.findall(indent, start_space))
|
||||
|
||||
if not skip:
|
||||
if line_indents > current_indent:
|
||||
yield "%s{" % (indent * current_indent)
|
||||
current_indent = line_indents
|
||||
if not skip:
|
||||
if line_indents > current_indent:
|
||||
yield "%s{" % (indent * current_indent)
|
||||
current_indent = line_indents
|
||||
|
||||
while line_indents < current_indent:
|
||||
current_indent -= 1
|
||||
yield "%s}" % (indent * current_indent)
|
||||
while line_indents < current_indent:
|
||||
current_indent -= 1
|
||||
yield "%s}" % (indent * current_indent)
|
||||
|
||||
line = _add_property(line)
|
||||
line = _add_property(line)
|
||||
|
||||
yield line
|
||||
yield line
|
||||
|
||||
skip = any((line.endswith(e) for e in "([{+\\,?:"))
|
||||
skip = any((line.endswith(e) for e in "([{+\\,?:"))
|
||||
|
||||
while current_indent:
|
||||
current_indent -= 1
|
||||
yield "%s}" % (indent * current_indent)
|
||||
while current_indent:
|
||||
current_indent -= 1
|
||||
yield "%s}" % (indent * current_indent)
|
||||
|
||||
|
||||
def convert_to_qml(theme_content: str) -> str:
|
||||
"""Return valid QML code with imports from QPL content."""
|
||||
"""Return valid QML code with imports from QPL content."""
|
||||
|
||||
theme_content = theme_content.replace("\t", " ")
|
||||
theme_content = theme_content.replace("\t", " ")
|
||||
|
||||
lines = [
|
||||
"import QtQuick 2.12",
|
||||
'import "../Base"',
|
||||
"QtObject {",
|
||||
" function hsluv(h, s, l, a) { return utils.hsluv(h, s, l, a) }",
|
||||
" function hsl(h, s, l) { return utils.hsl(h, s, l) }",
|
||||
" function hsla(h, s, l, a) { return utils.hsla(h, s, l, a) }",
|
||||
" id: theme",
|
||||
]
|
||||
lines += [f" {line}" for line in _process_lines(theme_content)]
|
||||
lines += ["}"]
|
||||
lines = [
|
||||
"import QtQuick 2.12",
|
||||
'import "../Base"',
|
||||
"QtObject {",
|
||||
" function hsluv(h, s, l, a) { return utils.hsluv(h, s, l, a) }",
|
||||
" function hsl(h, s, l) { return utils.hsl(h, s, l) }",
|
||||
" function hsla(h, s, l, a) { return utils.hsla(h, s, l, a) }",
|
||||
" id: theme",
|
||||
]
|
||||
lines += [f" {line}" for line in _process_lines(theme_content)]
|
||||
lines += ["}"]
|
||||
|
||||
return "\n".join(lines)
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -12,7 +12,7 @@ from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple,
|
||||
TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple,
|
||||
)
|
||||
|
||||
import pyotherside
|
||||
@@ -20,521 +20,521 @@ from watchgod import Change, awatch
|
||||
|
||||
from .pcn.section import Section
|
||||
from .pyotherside_events import (
|
||||
LoopException, Pre070SettingsDetected, UserFileChanged,
|
||||
LoopException, Pre070SettingsDetected, UserFileChanged,
|
||||
)
|
||||
from .theme_parser import convert_to_qml
|
||||
from .utils import (
|
||||
aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive,
|
||||
flatten_dict_keys,
|
||||
aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive,
|
||||
flatten_dict_keys,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .backend import Backend
|
||||
from .backend import Backend
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFile:
|
||||
"""Base class representing a user config or data file."""
|
||||
"""Base class representing a user config or data file."""
|
||||
|
||||
create_missing: ClassVar[bool] = True
|
||||
create_missing: ClassVar[bool] = True
|
||||
|
||||
backend: "Backend" = field(repr=False)
|
||||
filename: str = field()
|
||||
parent: Optional["UserFile"] = None
|
||||
children: Dict[Path, "UserFile"] = field(default_factory=dict)
|
||||
backend: "Backend" = field(repr=False)
|
||||
filename: str = field()
|
||||
parent: Optional["UserFile"] = None
|
||||
children: Dict[Path, "UserFile"] = field(default_factory=dict)
|
||||
|
||||
data: Any = field(init=False, default_factory=dict)
|
||||
_need_write: bool = field(init=False, default=False)
|
||||
_mtime: Optional[float] = field(init=False, default=None)
|
||||
data: Any = field(init=False, default_factory=dict)
|
||||
_need_write: bool = field(init=False, default=False)
|
||||
_mtime: Optional[float] = field(init=False, default=None)
|
||||
|
||||
_reader: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
_writer: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
_reader: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
_writer: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
def __post_init__(self) -> None:
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
|
||||
if self.path.exists():
|
||||
try:
|
||||
text = self.path.read_text()
|
||||
self.data, self._need_write = self.deserialized(text)
|
||||
except Exception as err: # noqa
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
if self.path.exists():
|
||||
try:
|
||||
text = self.path.read_text()
|
||||
self.data, self._need_write = self.deserialized(text)
|
||||
except Exception as err: # noqa
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
|
||||
self._reader = asyncio.ensure_future(self._start_reader())
|
||||
self._writer = asyncio.ensure_future(self._start_writer())
|
||||
self._reader = asyncio.ensure_future(self._start_reader())
|
||||
self._writer = asyncio.ensure_future(self._start_writer())
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Full path of the file to read, can exist or not exist."""
|
||||
raise NotImplementedError()
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Full path of the file to read, can exist or not exist."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def write_path(self) -> Path:
|
||||
"""Full path of the file to write, can exist or not exist."""
|
||||
return self.path
|
||||
@property
|
||||
def write_path(self) -> Path:
|
||||
"""Full path of the file to write, can exist or not exist."""
|
||||
return self.path
|
||||
|
||||
@property
|
||||
def default_data(self) -> Any:
|
||||
"""Default deserialized content to use if the file doesn't exist."""
|
||||
raise NotImplementedError()
|
||||
@property
|
||||
def default_data(self) -> Any:
|
||||
"""Default deserialized content to use if the file doesn't exist."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def qml_data(self) -> Any:
|
||||
"""Data converted for usage in QML."""
|
||||
return self.data
|
||||
@property
|
||||
def qml_data(self) -> Any:
|
||||
"""Data converted for usage in QML."""
|
||||
return self.data
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[Any, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`."""
|
||||
return (data, False)
|
||||
def deserialized(self, data: str) -> Tuple[Any, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`."""
|
||||
return (data, False)
|
||||
|
||||
def serialized(self) -> str:
|
||||
"""Return text from `UserFile.data` that can be written to disk."""
|
||||
raise NotImplementedError()
|
||||
def serialized(self) -> str:
|
||||
"""Return text from `UserFile.data` that can be written to disk."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Inform the disk writer coroutine that the data has changed."""
|
||||
self._need_write = True
|
||||
def save(self) -> None:
|
||||
"""Inform the disk writer coroutine that the data has changed."""
|
||||
self._need_write = True
|
||||
|
||||
def stop_watching(self) -> None:
|
||||
"""Stop watching the on-disk file for changes."""
|
||||
if self._reader:
|
||||
self._reader.cancel()
|
||||
def stop_watching(self) -> None:
|
||||
"""Stop watching the on-disk file for changes."""
|
||||
if self._reader:
|
||||
self._reader.cancel()
|
||||
|
||||
if self._writer:
|
||||
self._writer.cancel()
|
||||
if self._writer:
|
||||
self._writer.cancel()
|
||||
|
||||
for child in self.children.values():
|
||||
child.stop_watching()
|
||||
for child in self.children.values():
|
||||
child.stop_watching()
|
||||
|
||||
|
||||
async def set_data(self, data: Any) -> None:
|
||||
"""Set `data` and call `save()`, conveniance method for QML."""
|
||||
self.data = data
|
||||
self.save()
|
||||
async def set_data(self, data: Any) -> None:
|
||||
"""Set `data` and call `save()`, conveniance method for QML."""
|
||||
self.data = data
|
||||
self.save()
|
||||
|
||||
async def update_from_file(self) -> None:
|
||||
"""Read file at `path`, update `data` and call `save()` if needed."""
|
||||
async def update_from_file(self) -> None:
|
||||
"""Read file at `path`, update `data` and call `save()` if needed."""
|
||||
|
||||
if not self.path.exists():
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
return
|
||||
if not self.path.exists():
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
return
|
||||
|
||||
async with aiopen(self.path) as file:
|
||||
self.data, self._need_write = self.deserialized(await file.read())
|
||||
async with aiopen(self.path) as file:
|
||||
self.data, self._need_write = self.deserialized(await file.read())
|
||||
|
||||
async def _start_reader(self) -> None:
|
||||
"""Disk reader coroutine, watches for file changes to update `data`."""
|
||||
async def _start_reader(self) -> None:
|
||||
"""Disk reader coroutine, watches for file changes to update `data`."""
|
||||
|
||||
while not self.path.exists():
|
||||
await asyncio.sleep(1)
|
||||
while not self.path.exists():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async for changes in awatch(self.path):
|
||||
try:
|
||||
ignored = 0
|
||||
async for changes in awatch(self.path):
|
||||
try:
|
||||
ignored = 0
|
||||
|
||||
for change in changes:
|
||||
if change[0] in (Change.added, Change.modified):
|
||||
mtime = self.path.stat().st_mtime
|
||||
for change in changes:
|
||||
if change[0] in (Change.added, Change.modified):
|
||||
mtime = self.path.stat().st_mtime
|
||||
|
||||
if mtime == self._mtime:
|
||||
ignored += 1
|
||||
continue
|
||||
if mtime == self._mtime:
|
||||
ignored += 1
|
||||
continue
|
||||
|
||||
await self.update_from_file()
|
||||
self._mtime = mtime
|
||||
await self.update_from_file()
|
||||
self._mtime = mtime
|
||||
|
||||
elif change[0] == Change.deleted:
|
||||
self._mtime = None
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
elif change[0] == Change.deleted:
|
||||
self._mtime = None
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
|
||||
if changes and ignored < len(changes):
|
||||
UserFileChanged(type(self), self.qml_data)
|
||||
if changes and ignored < len(changes):
|
||||
UserFileChanged(type(self), self.qml_data)
|
||||
|
||||
parent = self.parent
|
||||
while parent:
|
||||
await parent.update_from_file()
|
||||
UserFileChanged(type(parent), parent.qml_data)
|
||||
parent = parent.parent
|
||||
parent = self.parent
|
||||
while parent:
|
||||
await parent.update_from_file()
|
||||
UserFileChanged(type(parent), parent.qml_data)
|
||||
parent = parent.parent
|
||||
|
||||
while not self.path.exists():
|
||||
# Prevent error spam after file gets deleted
|
||||
await asyncio.sleep(0.5)
|
||||
while not self.path.exists():
|
||||
# Prevent error spam after file gets deleted
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as err: # noqa
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
except Exception as err: # noqa
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
|
||||
async def _start_writer(self) -> None:
|
||||
"""Disk writer coroutine, update the file with a 1 second cooldown."""
|
||||
async def _start_writer(self) -> None:
|
||||
"""Disk writer coroutine, update the file with a 1 second cooldown."""
|
||||
|
||||
if self.write_path.parts[0] == "qrc:":
|
||||
return
|
||||
if self.write_path.parts[0] == "qrc:":
|
||||
return
|
||||
|
||||
self.write_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.write_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
if self._need_write:
|
||||
async with atomic_write(self.write_path) as (new, done):
|
||||
await new.write(self.serialized())
|
||||
done()
|
||||
try:
|
||||
if self._need_write:
|
||||
async with atomic_write(self.write_path) as (new, done):
|
||||
await new.write(self.serialized())
|
||||
done()
|
||||
|
||||
self._need_write = False
|
||||
self._mtime = self.write_path.stat().st_mtime
|
||||
self._need_write = False
|
||||
self._mtime = self.write_path.stat().st_mtime
|
||||
|
||||
except Exception as err: # noqa
|
||||
self._need_write = False
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
except Exception as err: # noqa
|
||||
self._need_write = False
|
||||
LoopException(str(err), err, traceback.format_exc().rstrip())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigFile(UserFile):
|
||||
"""A file that goes in the configuration directory, e.g. ~/.config/app."""
|
||||
"""A file that goes in the configuration directory, e.g. ~/.config/app."""
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MOMENT_CONFIG_DIR") or
|
||||
self.backend.appdirs.user_config_dir,
|
||||
) / self.filename
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MOMENT_CONFIG_DIR") or
|
||||
self.backend.appdirs.user_config_dir,
|
||||
) / self.filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserDataFile(UserFile):
|
||||
"""A file that goes in the user data directory, e.g. ~/.local/share/app."""
|
||||
"""A file that goes in the user data directory, e.g. ~/.local/share/app."""
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
) / self.filename
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
) / self.filename
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappingFile(MutableMapping, UserFile):
|
||||
"""A file manipulable like a dict. `data` must be a mutable mapping."""
|
||||
"""A file manipulable like a dict. `data` must be a mutable mapping."""
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self.data[key]
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
self.data[key] = value
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
self.data[key] = value
|
||||
|
||||
def __delitem__(self, key: Any) -> None:
|
||||
del self.data[key]
|
||||
def __delitem__(self, key: Any) -> None:
|
||||
del self.data[key]
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self.data)
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self.data)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.data)
|
||||
def __len__(self) -> int:
|
||||
return len(self.data)
|
||||
|
||||
def __getattr__(self, key: Any) -> Any:
|
||||
try:
|
||||
return self.data[key]
|
||||
except KeyError:
|
||||
return super().__getattribute__(key)
|
||||
def __getattr__(self, key: Any) -> Any:
|
||||
try:
|
||||
return self.data[key]
|
||||
except KeyError:
|
||||
return super().__getattribute__(key)
|
||||
|
||||
def __setattr__(self, key: Any, value: Any) -> None:
|
||||
if key in self.__dataclass_fields__:
|
||||
super().__setattr__(key, value)
|
||||
return
|
||||
def __setattr__(self, key: Any, value: Any) -> None:
|
||||
if key in self.__dataclass_fields__:
|
||||
super().__setattr__(key, value)
|
||||
return
|
||||
|
||||
self.data[key] = value
|
||||
self.data[key] = value
|
||||
|
||||
def __delattr__(self, key: Any) -> None:
|
||||
del self.data[key]
|
||||
def __delattr__(self, key: Any) -> None:
|
||||
del self.data[key]
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONFile(MappingFile):
|
||||
"""A file stored on disk in the JSON format."""
|
||||
"""A file stored on disk in the JSON format."""
|
||||
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {}
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {}
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`.
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`.
|
||||
|
||||
If the file has missing keys, the missing data will be merged to the
|
||||
returned dict and the second tuple item will be `True`.
|
||||
"""
|
||||
If the file has missing keys, the missing data will be merged to the
|
||||
returned dict and the second tuple item will be `True`.
|
||||
"""
|
||||
|
||||
loaded = json.loads(data)
|
||||
all_data = self.default_data.copy()
|
||||
dict_update_recursive(all_data, loaded)
|
||||
return (all_data, loaded != all_data)
|
||||
loaded = json.loads(data)
|
||||
all_data = self.default_data.copy()
|
||||
dict_update_recursive(all_data, loaded)
|
||||
return (all_data, loaded != all_data)
|
||||
|
||||
def serialized(self) -> str:
|
||||
data = self.data
|
||||
return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
def serialized(self) -> str:
|
||||
data = self.data
|
||||
return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PCNFile(MappingFile):
|
||||
"""File stored in the PCN format, with machine edits in a separate JSON."""
|
||||
"""File stored in the PCN format, with machine edits in a separate JSON."""
|
||||
|
||||
create_missing = False
|
||||
create_missing = False
|
||||
|
||||
path_override: Optional[Path] = None
|
||||
path_override: Optional[Path] = None
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self.path_override or super().path
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self.path_override or super().path
|
||||
|
||||
@property
|
||||
def write_path(self) -> Path:
|
||||
"""Full path of file where programatically-done edits are stored."""
|
||||
return self.path.with_suffix(".gui.json")
|
||||
@property
|
||||
def write_path(self) -> Path:
|
||||
"""Full path of file where programatically-done edits are stored."""
|
||||
return self.path.with_suffix(".gui.json")
|
||||
|
||||
@property
|
||||
def qml_data(self) -> Dict[str, Any]:
|
||||
return deep_serialize_for_qml(self.data.as_dict()) # type: ignore
|
||||
@property
|
||||
def qml_data(self) -> Dict[str, Any]:
|
||||
return deep_serialize_for_qml(self.data.as_dict()) # type: ignore
|
||||
|
||||
@property
|
||||
def default_data(self) -> Section:
|
||||
return Section()
|
||||
@property
|
||||
def default_data(self) -> Section:
|
||||
return Section()
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[Section, bool]:
|
||||
root = Section.from_source_code(data, self.path)
|
||||
edits = "{}"
|
||||
def deserialized(self, data: str) -> Tuple[Section, bool]:
|
||||
root = Section.from_source_code(data, self.path)
|
||||
edits = "{}"
|
||||
|
||||
if self.write_path.exists():
|
||||
edits = self.write_path.read_text()
|
||||
if self.write_path.exists():
|
||||
edits = self.write_path.read_text()
|
||||
|
||||
includes_now = list(root.all_includes)
|
||||
includes_now = list(root.all_includes)
|
||||
|
||||
for path, pcn in self.children.copy().items():
|
||||
if path not in includes_now:
|
||||
pcn.stop_watching()
|
||||
del self.children[path]
|
||||
for path, pcn in self.children.copy().items():
|
||||
if path not in includes_now:
|
||||
pcn.stop_watching()
|
||||
del self.children[path]
|
||||
|
||||
for path in includes_now:
|
||||
if path not in self.children:
|
||||
self.children[path] = PCNFile(
|
||||
self.backend,
|
||||
filename = path.name,
|
||||
parent = self,
|
||||
path_override = path,
|
||||
)
|
||||
for path in includes_now:
|
||||
if path not in self.children:
|
||||
self.children[path] = PCNFile(
|
||||
self.backend,
|
||||
filename = path.name,
|
||||
parent = self,
|
||||
path_override = path,
|
||||
)
|
||||
|
||||
return (root, root.deep_merge_edits(json.loads(edits)))
|
||||
return (root, root.deep_merge_edits(json.loads(edits)))
|
||||
|
||||
def serialized(self) -> str:
|
||||
edits = self.data.edits_as_dict()
|
||||
return json.dumps(edits, indent=4, ensure_ascii=False)
|
||||
def serialized(self) -> str:
|
||||
edits = self.data.edits_as_dict()
|
||||
return json.dumps(edits, indent=4, ensure_ascii=False)
|
||||
|
||||
async def set_data(self, data: Dict[str, Any]) -> None:
|
||||
self.data.deep_merge_edits({"set": data}, has_expressions=False)
|
||||
self.save()
|
||||
async def set_data(self, data: Dict[str, Any]) -> None:
|
||||
self.data.deep_merge_edits({"set": data}, has_expressions=False)
|
||||
self.save()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Accounts(ConfigFile, JSONFile):
|
||||
"""Config file for saved matrix accounts: user ID, access tokens, etc"""
|
||||
"""Config file for saved matrix accounts: user ID, access tokens, etc"""
|
||||
|
||||
filename: str = "accounts.json"
|
||||
filename: str = "accounts.json"
|
||||
|
||||
async def any_saved(self) -> bool:
|
||||
"""Return for QML whether there are any accounts saved on disk."""
|
||||
return bool(self.data)
|
||||
async def any_saved(self) -> bool:
|
||||
"""Return for QML whether there are any accounts saved on disk."""
|
||||
return bool(self.data)
|
||||
|
||||
async def add(self, user_id: str) -> None:
|
||||
"""Add an account to the config and write it on disk.
|
||||
async def add(self, user_id: str) -> None:
|
||||
"""Add an account to the config and write it on disk.
|
||||
|
||||
The account's details such as its access token are retrieved from
|
||||
the corresponding `MatrixClient` in `backend.clients`.
|
||||
"""
|
||||
The account's details such as its access token are retrieved from
|
||||
the corresponding `MatrixClient` in `backend.clients`.
|
||||
"""
|
||||
|
||||
client = self.backend.clients[user_id]
|
||||
account = self.backend.models["accounts"][user_id]
|
||||
client = self.backend.clients[user_id]
|
||||
account = self.backend.models["accounts"][user_id]
|
||||
|
||||
self.update({
|
||||
client.user_id: {
|
||||
"homeserver": client.homeserver,
|
||||
"token": client.access_token,
|
||||
"device_id": client.device_id,
|
||||
"enabled": True,
|
||||
"presence": account.presence.value.replace("echo_", ""),
|
||||
"status_msg": account.status_msg,
|
||||
"order": account.order,
|
||||
},
|
||||
})
|
||||
self.save()
|
||||
self.update({
|
||||
client.user_id: {
|
||||
"homeserver": client.homeserver,
|
||||
"token": client.access_token,
|
||||
"device_id": client.device_id,
|
||||
"enabled": True,
|
||||
"presence": account.presence.value.replace("echo_", ""),
|
||||
"status_msg": account.status_msg,
|
||||
"order": account.order,
|
||||
},
|
||||
})
|
||||
self.save()
|
||||
|
||||
async def set(
|
||||
self,
|
||||
user_id: str,
|
||||
enabled: Optional[str] = None,
|
||||
presence: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
status_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update an account if found in the config file and write to disk."""
|
||||
async def set(
|
||||
self,
|
||||
user_id: str,
|
||||
enabled: Optional[str] = None,
|
||||
presence: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
status_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update an account if found in the config file and write to disk."""
|
||||
|
||||
if user_id not in self:
|
||||
return
|
||||
if user_id not in self:
|
||||
return
|
||||
|
||||
if enabled is not None:
|
||||
self[user_id]["enabled"] = enabled
|
||||
if enabled is not None:
|
||||
self[user_id]["enabled"] = enabled
|
||||
|
||||
if presence is not None:
|
||||
self[user_id]["presence"] = presence
|
||||
if presence is not None:
|
||||
self[user_id]["presence"] = presence
|
||||
|
||||
if order is not None:
|
||||
self[user_id]["order"] = order
|
||||
if order is not None:
|
||||
self[user_id]["order"] = order
|
||||
|
||||
if status_msg is not None:
|
||||
self[user_id]["status_msg"] = status_msg
|
||||
if status_msg is not None:
|
||||
self[user_id]["status_msg"] = status_msg
|
||||
|
||||
self.save()
|
||||
self.save()
|
||||
|
||||
async def forget(self, user_id: str) -> None:
|
||||
"""Delete an account from the config and write it on disk."""
|
||||
async def forget(self, user_id: str) -> None:
|
||||
"""Delete an account from the config and write it on disk."""
|
||||
|
||||
self.pop(user_id, None)
|
||||
self.save()
|
||||
self.pop(user_id, None)
|
||||
self.save()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Pre070Settings(ConfigFile):
|
||||
"""Detect and warn about the presence of a pre-0.7.0 settings.json file."""
|
||||
"""Detect and warn about the presence of a pre-0.7.0 settings.json file."""
|
||||
|
||||
filename: str = "settings.json"
|
||||
filename: str = "settings.json"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.path.exists():
|
||||
Pre070SettingsDetected(self.path)
|
||||
def __post_init__(self) -> None:
|
||||
if self.path.exists():
|
||||
Pre070SettingsDetected(self.path)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings(ConfigFile, PCNFile):
|
||||
"""General config file for UI and backend settings"""
|
||||
"""General config file for UI and backend settings"""
|
||||
|
||||
filename: str = "settings.py"
|
||||
filename: str = "settings.py"
|
||||
|
||||
@property
|
||||
def default_data(self) -> Section:
|
||||
root = Section.from_file("src/config/settings.py")
|
||||
edits = "{}"
|
||||
@property
|
||||
def default_data(self) -> Section:
|
||||
root = Section.from_file("src/config/settings.py")
|
||||
edits = "{}"
|
||||
|
||||
if self.write_path.exists():
|
||||
edits = self.write_path.read_text()
|
||||
if self.write_path.exists():
|
||||
edits = self.write_path.read_text()
|
||||
|
||||
root.deep_merge_edits(json.loads(edits))
|
||||
return root
|
||||
root.deep_merge_edits(json.loads(edits))
|
||||
return root
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[Section, bool]:
|
||||
section, save = super().deserialized(data)
|
||||
def deserialized(self, data: str) -> Tuple[Section, bool]:
|
||||
section, save = super().deserialized(data)
|
||||
|
||||
if self and self.General.theme != section.General.theme:
|
||||
if hasattr(self.backend, "theme"):
|
||||
self.backend.theme.stop_watching()
|
||||
if self and self.General.theme != section.General.theme:
|
||||
if hasattr(self.backend, "theme"):
|
||||
self.backend.theme.stop_watching()
|
||||
|
||||
self.backend.theme = Theme(
|
||||
self.backend, section.General.theme, # type: ignore
|
||||
)
|
||||
UserFileChanged(Theme, self.backend.theme.qml_data)
|
||||
self.backend.theme = Theme(
|
||||
self.backend, section.General.theme, # type: ignore
|
||||
)
|
||||
UserFileChanged(Theme, self.backend.theme.qml_data)
|
||||
|
||||
# if self and self.General.new_theme != section.General.new_theme:
|
||||
# self.backend.new_theme.stop_watching()
|
||||
# self.backend.new_theme = NewTheme(
|
||||
# self.backend, section.General.new_theme, # type: ignore
|
||||
# )
|
||||
# UserFileChanged(Theme, self.backend.new_theme.qml_data)
|
||||
# if self and self.General.new_theme != section.General.new_theme:
|
||||
# self.backend.new_theme.stop_watching()
|
||||
# self.backend.new_theme = NewTheme(
|
||||
# self.backend, section.General.new_theme, # type: ignore
|
||||
# )
|
||||
# UserFileChanged(Theme, self.backend.new_theme.qml_data)
|
||||
|
||||
return (section, save)
|
||||
return (section, save)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewTheme(UserDataFile, PCNFile):
|
||||
"""A theme file defining the look of QML components."""
|
||||
"""A theme file defining the look of QML components."""
|
||||
|
||||
create_missing = False
|
||||
create_missing = False
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
data_dir = Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
)
|
||||
return data_dir / "themes" / self.filename
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
data_dir = Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
)
|
||||
return data_dir / "themes" / self.filename
|
||||
|
||||
@property
|
||||
def qml_data(self) -> Dict[str, Any]:
|
||||
return flatten_dict_keys(super().qml_data, last_level=False)
|
||||
@property
|
||||
def qml_data(self) -> Dict[str, Any]:
|
||||
return flatten_dict_keys(super().qml_data, last_level=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIState(UserDataFile, JSONFile):
|
||||
"""File used to save and restore the state of QML components."""
|
||||
"""File used to save and restore the state of QML components."""
|
||||
|
||||
filename: str = "state.json"
|
||||
filename: str = "state.json"
|
||||
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {
|
||||
"collapseAccounts": {},
|
||||
"page": "Pages/Default.qml",
|
||||
"pageProperties": {},
|
||||
}
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {
|
||||
"collapseAccounts": {},
|
||||
"page": "Pages/Default.qml",
|
||||
"pageProperties": {},
|
||||
}
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
dict_data, save = super().deserialized(data)
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
dict_data, save = super().deserialized(data)
|
||||
|
||||
for user_id, do in dict_data["collapseAccounts"].items():
|
||||
self.backend.models["all_rooms"].set_account_collapse(user_id, do)
|
||||
for user_id, do in dict_data["collapseAccounts"].items():
|
||||
self.backend.models["all_rooms"].set_account_collapse(user_id, do)
|
||||
|
||||
return (dict_data, save)
|
||||
return (dict_data, save)
|
||||
|
||||
|
||||
@dataclass
|
||||
class History(UserDataFile, JSONFile):
|
||||
"""File to save and restore lines typed by the user in QML components."""
|
||||
"""File to save and restore lines typed by the user in QML components."""
|
||||
|
||||
filename: str = "history.json"
|
||||
filename: str = "history.json"
|
||||
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {"console": []}
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {"console": []}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Theme(UserDataFile):
|
||||
"""A theme file defining the look of QML components."""
|
||||
"""A theme file defining the look of QML components."""
|
||||
|
||||
# Since it currently breaks at every update and the file format will be
|
||||
# changed later, don't copy the theme to user data dir if it doesn't exist.
|
||||
create_missing = False
|
||||
# Since it currently breaks at every update and the file format will be
|
||||
# changed later, don't copy the theme to user data dir if it doesn't exist.
|
||||
create_missing = False
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
data_dir = Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
)
|
||||
return data_dir / "themes" / self.filename
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
data_dir = Path(
|
||||
os.environ.get("MOMENT_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
)
|
||||
return data_dir / "themes" / self.filename
|
||||
|
||||
@property
|
||||
def default_data(self) -> str:
|
||||
if self.filename in ("Foliage.qpl", "Midnight.qpl", "Glass.qpl"):
|
||||
path = f"src/themes/{self.filename}"
|
||||
else:
|
||||
path = "src/themes/Foliage.qpl"
|
||||
@property
|
||||
def default_data(self) -> str:
|
||||
if self.filename in ("Foliage.qpl", "Midnight.qpl", "Glass.qpl"):
|
||||
path = f"src/themes/{self.filename}"
|
||||
else:
|
||||
path = "src/themes/Foliage.qpl"
|
||||
|
||||
try:
|
||||
byte_content = pyotherside.qrc_get_file_contents(path)
|
||||
except ValueError:
|
||||
# App was compiled without QRC
|
||||
return convert_to_qml(Path(path).read_text())
|
||||
else:
|
||||
return convert_to_qml(byte_content.decode())
|
||||
try:
|
||||
byte_content = pyotherside.qrc_get_file_contents(path)
|
||||
except ValueError:
|
||||
# App was compiled without QRC
|
||||
return convert_to_qml(Path(path).read_text())
|
||||
else:
|
||||
return convert_to_qml(byte_content.decode())
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[str, bool]:
|
||||
return (convert_to_qml(data), False)
|
||||
def deserialized(self, data: str) -> Tuple[str, bool]:
|
||||
return (convert_to_qml(data), False)
|
||||
|
||||
@@ -14,14 +14,15 @@ import xml.etree.cElementTree as xml_etree
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from contextlib import suppress
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from difflib import SequenceMatcher
|
||||
from enum import Enum
|
||||
from enum import auto as autostr
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
from types import ModuleType
|
||||
from typing import (
|
||||
Any, AsyncIterator, Callable, Collection, Dict, Iterable, Mapping,
|
||||
Optional, Tuple, Type, Union,
|
||||
Any, AsyncIterator, Callable, Collection, Dict, Iterable, Mapping,
|
||||
Optional, Tuple, Type, Union,
|
||||
)
|
||||
from uuid import UUID
|
||||
|
||||
@@ -36,348 +37,370 @@ from .color import Color
|
||||
from .pcn.section import Section
|
||||
|
||||
if sys.version_info >= (3, 7):
|
||||
from contextlib import asynccontextmanager
|
||||
current_task = asyncio.current_task
|
||||
from contextlib import asynccontextmanager
|
||||
current_task = asyncio.current_task
|
||||
else:
|
||||
from async_generator import asynccontextmanager
|
||||
current_task = asyncio.Task.current_task
|
||||
from async_generator import asynccontextmanager
|
||||
current_task = asyncio.Task.current_task
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
import collections.abc as collections
|
||||
import collections.abc as collections
|
||||
else:
|
||||
import collections
|
||||
import collections
|
||||
|
||||
Size = Tuple[int, int]
|
||||
Size = Tuple[int, int]
|
||||
BytesOrPIL = Union[bytes, PILImage.Image]
|
||||
auto = autostr
|
||||
auto = autostr
|
||||
|
||||
COMPRESSION_POOL = ProcessPoolExecutor()
|
||||
|
||||
|
||||
class AutoStrEnum(Enum):
|
||||
"""An Enum where auto() assigns the member's name instead of an integer.
|
||||
"""An Enum where auto() assigns the member's name instead of an integer.
|
||||
|
||||
Example:
|
||||
>>> class Fruits(AutoStrEnum): apple = auto()
|
||||
>>> Fruits.apple.value
|
||||
"apple"
|
||||
"""
|
||||
Example:
|
||||
>>> class Fruits(AutoStrEnum): apple = auto()
|
||||
>>> Fruits.apple.value
|
||||
"apple"
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _generate_next_value_(name, *_):
|
||||
return name
|
||||
@staticmethod
|
||||
def _generate_next_value_(name, *_):
|
||||
return name
|
||||
|
||||
|
||||
def dict_update_recursive(dict1: dict, dict2: dict) -> None:
|
||||
"""Deep-merge `dict1` and `dict2`, recursive version of `dict.update()`."""
|
||||
# https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
|
||||
"""Deep-merge `dict1` and `dict2`, recursive version of `dict.update()`."""
|
||||
# https://gist.github.com/angstwad/bf22d1822c38a92ec0a9
|
||||
|
||||
for k in dict2:
|
||||
if (k in dict1 and isinstance(dict1[k], dict) and
|
||||
isinstance(dict2[k], collections.Mapping)):
|
||||
dict_update_recursive(dict1[k], dict2[k])
|
||||
else:
|
||||
dict1[k] = dict2[k]
|
||||
for k in dict2:
|
||||
if (k in dict1 and isinstance(dict1[k], dict) and
|
||||
isinstance(dict2[k], collections.Mapping)):
|
||||
dict_update_recursive(dict1[k], dict2[k])
|
||||
else:
|
||||
dict1[k] = dict2[k]
|
||||
|
||||
|
||||
def flatten_dict_keys(
|
||||
source: Optional[Dict[str, Any]] = None,
|
||||
separator: str = ".",
|
||||
last_level: bool = True,
|
||||
_flat: Optional[Dict[str, Any]] = None,
|
||||
_prefix: str = "",
|
||||
source: Optional[Dict[str, Any]] = None,
|
||||
separator: str = ".",
|
||||
last_level: bool = True,
|
||||
_flat: Optional[Dict[str, Any]] = None,
|
||||
_prefix: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a flattened version of the ``source`` dict.
|
||||
"""Return a flattened version of the ``source`` dict.
|
||||
|
||||
Example:
|
||||
>>> dct
|
||||
{"content": {"body": "foo"}, "m.test": {"key": {"bar": 1}}}
|
||||
>>> flatten_dict_keys(dct)
|
||||
{"content.body": "foo", "m.test.key.bar": 1}
|
||||
>>> flatten_dict_keys(dct, last_level=False)
|
||||
{"content": {"body": "foo"}, "m.test.key": {bar": 1}}
|
||||
"""
|
||||
Example:
|
||||
>>> dct
|
||||
{"content": {"body": "foo"}, "m.test": {"key": {"bar": 1}}}
|
||||
>>> flatten_dict_keys(dct)
|
||||
{"content.body": "foo", "m.test.key.bar": 1}
|
||||
>>> flatten_dict_keys(dct, last_level=False)
|
||||
{"content": {"body": "foo"}, "m.test.key": {bar": 1}}
|
||||
"""
|
||||
|
||||
flat = {} if _flat is None else _flat
|
||||
flat = {} if _flat is None else _flat
|
||||
|
||||
for key, value in (source or {}).items():
|
||||
if isinstance(value, dict):
|
||||
prefix = f"{_prefix}{key}{separator}"
|
||||
flatten_dict_keys(value, separator, last_level, flat, prefix)
|
||||
elif last_level:
|
||||
flat[f"{_prefix}{key}"] = value
|
||||
else:
|
||||
prefix = _prefix[:-len(separator)] # remove trailing separator
|
||||
flat.setdefault(prefix, {})[key] = value
|
||||
for key, value in (source or {}).items():
|
||||
if isinstance(value, dict):
|
||||
prefix = f"{_prefix}{key}{separator}"
|
||||
flatten_dict_keys(value, separator, last_level, flat, prefix)
|
||||
elif last_level:
|
||||
flat[f"{_prefix}{key}"] = value
|
||||
else:
|
||||
prefix = _prefix[:-len(separator)] # remove trailing separator
|
||||
flat.setdefault(prefix, {})[key] = value
|
||||
|
||||
return flat
|
||||
return flat
|
||||
|
||||
|
||||
def config_get_account_room_rule(
|
||||
rules: Section, user_id: str, room_id: str,
|
||||
rules: Section, user_id: str, room_id: str,
|
||||
) -> Any:
|
||||
"""Return best matching rule value for an account/room PCN free Section."""
|
||||
"""Return best matching rule value for an account/room PCN free Section."""
|
||||
|
||||
for name, value in reversed(rules.children()):
|
||||
name = re.sub(r"\s+", " ", name.strip())
|
||||
for name, value in reversed(rules.children()):
|
||||
name = re.sub(r"\s+", " ", name.strip())
|
||||
|
||||
if name in (user_id, room_id, f"{user_id} {room_id}"):
|
||||
return value
|
||||
if name in (user_id, room_id, f"{user_id} {room_id}"):
|
||||
return value
|
||||
|
||||
return rules.default
|
||||
return rules.default
|
||||
|
||||
|
||||
async def is_svg(file: File) -> bool:
|
||||
"""Return whether the file is a SVG (`lxml` is used for detection)."""
|
||||
"""Return whether the file is a SVG (`lxml` is used for detection)."""
|
||||
|
||||
chunks = [c async for c in async_generator_from_data(file)]
|
||||
chunks = [c async for c in async_generator_from_data(file)]
|
||||
|
||||
with io.BytesIO(b"".join(chunks)) as file:
|
||||
try:
|
||||
_, element = next(xml_etree.iterparse(file, ("start",)))
|
||||
return element.tag == "{http://www.w3.org/2000/svg}svg"
|
||||
except (StopIteration, xml_etree.ParseError):
|
||||
return False
|
||||
with io.BytesIO(b"".join(chunks)) as file:
|
||||
try:
|
||||
_, element = next(xml_etree.iterparse(file, ("start",)))
|
||||
return element.tag == "{http://www.w3.org/2000/svg}svg"
|
||||
except (StopIteration, xml_etree.ParseError):
|
||||
return False
|
||||
|
||||
|
||||
async def svg_dimensions(file: File) -> Size:
|
||||
"""Return the width and height, or viewBox width and height for a SVG.
|
||||
"""Return the width and height, or viewBox width and height for a SVG.
|
||||
|
||||
If these properties are missing (broken file), ``(256, 256)`` is returned.
|
||||
"""
|
||||
If these properties are missing (broken file), ``(256, 256)`` is returned.
|
||||
"""
|
||||
|
||||
chunks = [c async for c in async_generator_from_data(file)]
|
||||
chunks = [c async for c in async_generator_from_data(file)]
|
||||
|
||||
with io.BytesIO(b"".join(chunks)) as file:
|
||||
attrs = xml_etree.parse(file).getroot().attrib
|
||||
with io.BytesIO(b"".join(chunks)) as file:
|
||||
attrs = xml_etree.parse(file).getroot().attrib
|
||||
|
||||
try:
|
||||
width = round(float(attrs.get("width", attrs["viewBox"].split()[3])))
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
width = 256
|
||||
try:
|
||||
width = round(float(attrs.get("width", attrs["viewBox"].split()[3])))
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
width = 256
|
||||
|
||||
try:
|
||||
height = round(float(attrs.get("height", attrs["viewBox"].split()[4])))
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
height = 256
|
||||
try:
|
||||
height = round(float(attrs.get("height", attrs["viewBox"].split()[4])))
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
height = 256
|
||||
|
||||
return (width, height)
|
||||
return (width, height)
|
||||
|
||||
|
||||
async def guess_mime(file: File) -> str:
|
||||
"""Return the file's mimetype, or `application/octet-stream` if unknown."""
|
||||
"""Return the file's mimetype, or `application/octet-stream` if unknown."""
|
||||
|
||||
if isinstance(file, io.IOBase):
|
||||
file.seek(0, 0)
|
||||
elif isinstance(file, AsyncBufferedIOBase):
|
||||
await file.seek(0, 0)
|
||||
if isinstance(file, io.IOBase):
|
||||
file.seek(0, 0)
|
||||
elif isinstance(file, AsyncBufferedIOBase):
|
||||
await file.seek(0, 0)
|
||||
|
||||
try:
|
||||
first_chunk: bytes
|
||||
async for first_chunk in async_generator_from_data(file):
|
||||
break
|
||||
else:
|
||||
return "inode/x-empty" # empty file
|
||||
try:
|
||||
first_chunk: bytes
|
||||
async for first_chunk in async_generator_from_data(file):
|
||||
break
|
||||
else:
|
||||
return "inode/x-empty" # empty file
|
||||
|
||||
# TODO: plaintext
|
||||
mime = filetype.guess_mime(first_chunk)
|
||||
# TODO: plaintext
|
||||
mime = filetype.guess_mime(first_chunk)
|
||||
|
||||
return mime or (
|
||||
"image/svg+xml" if await is_svg(file) else
|
||||
"application/octet-stream"
|
||||
)
|
||||
finally:
|
||||
if isinstance(file, io.IOBase):
|
||||
file.seek(0, 0)
|
||||
elif isinstance(file, AsyncBufferedIOBase):
|
||||
await file.seek(0, 0)
|
||||
return mime or (
|
||||
"image/svg+xml" if await is_svg(file) else
|
||||
"application/octet-stream"
|
||||
)
|
||||
finally:
|
||||
if isinstance(file, io.IOBase):
|
||||
file.seek(0, 0)
|
||||
elif isinstance(file, AsyncBufferedIOBase):
|
||||
await file.seek(0, 0)
|
||||
|
||||
|
||||
def plain2html(text: str) -> str:
|
||||
"""Convert `\\n` into `<br>` tags and `\\t` into four spaces."""
|
||||
"""Convert `\\n` into `<br>` tags and `\\t` into four spaces."""
|
||||
|
||||
return html.escape(text)\
|
||||
.replace("\n", "<br>")\
|
||||
.replace("\t", " " * 4)
|
||||
return html.escape(text)\
|
||||
.replace("\n", "<br>")\
|
||||
.replace("\t", " " * 4)
|
||||
|
||||
|
||||
def strip_html_tags(text: str) -> str:
|
||||
"""Remove HTML tags from text."""
|
||||
return re.sub(r"<\/?[^>]+(>|$)", "", text)
|
||||
"""Remove HTML tags from text."""
|
||||
return re.sub(r"<\/?[^>]+(>|$)", "", text)
|
||||
|
||||
|
||||
def remove_reply(text: str):
|
||||
return re.sub(r"<mx-reply.*?>.*?<\/mx-reply>", "", text)
|
||||
|
||||
|
||||
def diff_body(a: str, b: str):
|
||||
sm = SequenceMatcher(None, remove_reply(a), remove_reply(b))
|
||||
output = []
|
||||
for opcode, a0, a1, b0, b1 in sm.get_opcodes():
|
||||
if opcode == "equal":
|
||||
output.append(sm.a[a0:a1])
|
||||
elif opcode == "insert":
|
||||
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
|
||||
elif opcode == "delete":
|
||||
output.append(f"<del>{sm.a[a0:a1]}</del>")
|
||||
elif opcode == "replace":
|
||||
output.append(f"<del>{sm.a[a0:a1]}</del>")
|
||||
output.append(f"<ins>{sm.b[b0:b1]}</ins>")
|
||||
else:
|
||||
raise RuntimeError(f"unexpected opcode: {opcode}")
|
||||
return "".join(output)
|
||||
|
||||
|
||||
def serialize_value_for_qml(
|
||||
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
|
||||
value: Any, json_list_dicts: bool = False, reject_unknown: bool = False,
|
||||
) -> Any:
|
||||
"""Convert a value to make it easier to use from QML.
|
||||
"""Convert a value to make it easier to use from QML.
|
||||
|
||||
Returns:
|
||||
Returns:
|
||||
|
||||
- For `bool`, `int`, `float`, `bytes`, `str`, `datetime`, `date`, `time`:
|
||||
the unchanged value (PyOtherSide handles these)
|
||||
- For `bool`, `int`, `float`, `bytes`, `str`, `datetime`, `date`, `time`:
|
||||
the unchanged value (PyOtherSide handles these)
|
||||
|
||||
- For `Collection` objects (includes `list` and `dict`):
|
||||
a JSON dump if `json_list_dicts` is `True`, else the unchanged value
|
||||
- For `Collection` objects (includes `list` and `dict`):
|
||||
a JSON dump if `json_list_dicts` is `True`, else the unchanged value
|
||||
|
||||
- If the value is an instancied object and has a `serialized` attribute or
|
||||
property, return that
|
||||
- If the value is an instancied object and has a `serialized` attribute or
|
||||
property, return that
|
||||
|
||||
- For `Enum` members, the actual value of the member
|
||||
- For `Enum` members, the actual value of the member
|
||||
|
||||
- For `Path` objects, a `file://<path...>` string
|
||||
- For `Path` objects, a `file://<path...>` string
|
||||
|
||||
- For `UUID` object: the UUID in string form
|
||||
- For `UUID` object: the UUID in string form
|
||||
|
||||
- For `timedelta` objects: the delta as a number of milliseconds `int`
|
||||
- For `timedelta` objects: the delta as a number of milliseconds `int`
|
||||
|
||||
- For `Color` objects: the color's hexadecimal value
|
||||
- For `Color` objects: the color's hexadecimal value
|
||||
|
||||
- For class types: the class `__name__`
|
||||
- For class types: the class `__name__`
|
||||
|
||||
- For anything else: raise a `TypeError` if `reject_unknown` is `True`,
|
||||
else return the unchanged value.
|
||||
"""
|
||||
- For anything else: raise a `TypeError` if `reject_unknown` is `True`,
|
||||
else return the unchanged value.
|
||||
"""
|
||||
|
||||
if isinstance(value, (bool, int, float, bytes, str, datetime, date, time)):
|
||||
return value
|
||||
if isinstance(value, (bool, int, float, bytes, str, datetime, date, time)):
|
||||
return value
|
||||
|
||||
if json_list_dicts and isinstance(value, Collection):
|
||||
if isinstance(value, set):
|
||||
value = list(value)
|
||||
return json.dumps(value)
|
||||
if json_list_dicts and isinstance(value, Collection):
|
||||
if isinstance(value, set):
|
||||
value = list(value)
|
||||
return json.dumps(value)
|
||||
|
||||
if not inspect.isclass(value) and hasattr(value, "serialized"):
|
||||
return value.serialized
|
||||
if not inspect.isclass(value) and hasattr(value, "serialized"):
|
||||
return value.serialized
|
||||
|
||||
if isinstance(value, Iterable):
|
||||
return value
|
||||
if isinstance(value, Iterable):
|
||||
return value
|
||||
|
||||
if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
|
||||
return value.value
|
||||
if hasattr(value, "__class__") and issubclass(value.__class__, Enum):
|
||||
return value.value
|
||||
|
||||
if isinstance(value, Path):
|
||||
return f"file://{value!s}"
|
||||
if isinstance(value, Path):
|
||||
return f"file://{value!s}"
|
||||
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
|
||||
if isinstance(value, timedelta):
|
||||
return value.total_seconds() * 1000
|
||||
if isinstance(value, timedelta):
|
||||
return value.total_seconds() * 1000
|
||||
|
||||
if isinstance(value, Color):
|
||||
return value.hex
|
||||
if isinstance(value, Color):
|
||||
return value.hex
|
||||
|
||||
if inspect.isclass(value):
|
||||
return value.__name__
|
||||
if inspect.isclass(value):
|
||||
return value.__name__
|
||||
|
||||
if reject_unknown:
|
||||
raise TypeError("Unknown type reject")
|
||||
if reject_unknown:
|
||||
raise TypeError("Unknown type reject")
|
||||
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def deep_serialize_for_qml(obj: Iterable) -> Union[list, dict]:
|
||||
"""Recursively serialize lists and dict values for QML."""
|
||||
"""Recursively serialize lists and dict values for QML."""
|
||||
|
||||
if isinstance(obj, Mapping):
|
||||
dct = {}
|
||||
if isinstance(obj, Mapping):
|
||||
dct = {}
|
||||
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, Iterable) and not isinstance(value, str):
|
||||
# PyOtherSide only accept dicts with string keys
|
||||
dct[str(key)] = deep_serialize_for_qml(value)
|
||||
continue
|
||||
for key, value in obj.items():
|
||||
if isinstance(value, Iterable) and not isinstance(value, str):
|
||||
# PyOtherSide only accept dicts with string keys
|
||||
dct[str(key)] = deep_serialize_for_qml(value)
|
||||
continue
|
||||
|
||||
with suppress(TypeError):
|
||||
dct[str(key)] = \
|
||||
serialize_value_for_qml(value, reject_unknown=True)
|
||||
with suppress(TypeError):
|
||||
dct[str(key)] = \
|
||||
serialize_value_for_qml(value, reject_unknown=True)
|
||||
|
||||
return dct
|
||||
return dct
|
||||
|
||||
lst = []
|
||||
lst = []
|
||||
|
||||
for value in obj:
|
||||
if isinstance(value, Iterable) and not isinstance(value, str):
|
||||
lst.append(deep_serialize_for_qml(value))
|
||||
continue
|
||||
for value in obj:
|
||||
if isinstance(value, Iterable) and not isinstance(value, str):
|
||||
lst.append(deep_serialize_for_qml(value))
|
||||
continue
|
||||
|
||||
with suppress(TypeError):
|
||||
lst.append(serialize_value_for_qml(value, reject_unknown=True))
|
||||
with suppress(TypeError):
|
||||
lst.append(serialize_value_for_qml(value, reject_unknown=True))
|
||||
|
||||
return lst
|
||||
return lst
|
||||
|
||||
|
||||
def classes_defined_in(module: ModuleType) -> Dict[str, Type]:
|
||||
"""Return a `{name: class}` dict of all the classes a module defines."""
|
||||
"""Return a `{name: class}` dict of all the classes a module defines."""
|
||||
|
||||
return {
|
||||
m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass)
|
||||
if not m[0].startswith("_") and
|
||||
m[1].__module__.startswith(module.__name__)
|
||||
}
|
||||
return {
|
||||
m[0]: m[1] for m in inspect.getmembers(module, inspect.isclass)
|
||||
if not m[0].startswith("_") and
|
||||
m[1].__module__.startswith(module.__name__)
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def aiopen(*args, **kwargs) -> AsyncIterator[Any]:
|
||||
"""Wrapper for `aiofiles.open()` that doesn't break mypy"""
|
||||
async with aiofiles.open(*args, **kwargs) as file:
|
||||
yield file
|
||||
"""Wrapper for `aiofiles.open()` that doesn't break mypy"""
|
||||
async with aiofiles.open(*args, **kwargs) as file:
|
||||
yield file
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def atomic_write(
|
||||
path: Union[Path, str], binary: bool = False, **kwargs,
|
||||
path: Union[Path, str], binary: bool = False, **kwargs,
|
||||
) -> AsyncIterator[Tuple[Any, Callable[[], None]]]:
|
||||
"""Write a file asynchronously (using aiofiles) and atomically.
|
||||
"""Write a file asynchronously (using aiofiles) and atomically.
|
||||
|
||||
Yields a `(open_temporary_file, done_function)` tuple.
|
||||
The done function should be called after writing to the given file.
|
||||
When the context manager exits, the temporary file will either replace
|
||||
`path` if the function was called, or be deleted.
|
||||
Yields a `(open_temporary_file, done_function)` tuple.
|
||||
The done function should be called after writing to the given file.
|
||||
When the context manager exits, the temporary file will either replace
|
||||
`path` if the function was called, or be deleted.
|
||||
|
||||
Example:
|
||||
>>> async with atomic_write("foo.txt") as (file, done):
|
||||
>>> await file.write("Sample text")
|
||||
>>> done()
|
||||
"""
|
||||
Example:
|
||||
>>> async with atomic_write("foo.txt") as (file, done):
|
||||
>>> await file.write("Sample text")
|
||||
>>> done()
|
||||
"""
|
||||
|
||||
mode = "wb" if binary else "w"
|
||||
path = Path(path)
|
||||
temp = NamedTemporaryFile(dir=path.parent, delete=False)
|
||||
temp_path = Path(temp.name)
|
||||
mode = "wb" if binary else "w"
|
||||
path = Path(path)
|
||||
temp = NamedTemporaryFile(dir=path.parent, delete=False)
|
||||
temp_path = Path(temp.name)
|
||||
|
||||
can_replace = False
|
||||
can_replace = False
|
||||
|
||||
def done() -> None:
|
||||
nonlocal can_replace
|
||||
can_replace = True
|
||||
def done() -> None:
|
||||
nonlocal can_replace
|
||||
can_replace = True
|
||||
|
||||
try:
|
||||
async with aiopen(temp_path, mode, **kwargs) as out:
|
||||
yield (out, done)
|
||||
finally:
|
||||
if can_replace:
|
||||
temp_path.replace(path)
|
||||
else:
|
||||
temp_path.unlink()
|
||||
try:
|
||||
async with aiopen(temp_path, mode, **kwargs) as out:
|
||||
yield (out, done)
|
||||
finally:
|
||||
if can_replace:
|
||||
temp_path.replace(path)
|
||||
else:
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
def _compress(image: BytesOrPIL, fmt: str, optimize: bool) -> bytes:
|
||||
if isinstance(image, bytes):
|
||||
pil_image = PILImage.open(io.BytesIO(image))
|
||||
else:
|
||||
pil_image = image
|
||||
if isinstance(image, bytes):
|
||||
pil_image = PILImage.open(io.BytesIO(image))
|
||||
else:
|
||||
pil_image = image
|
||||
|
||||
with io.BytesIO() as buffer:
|
||||
pil_image.save(buffer, fmt, optimize=optimize)
|
||||
return buffer.getvalue()
|
||||
with io.BytesIO() as buffer:
|
||||
pil_image.save(buffer, fmt, optimize=optimize)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
async def compress_image(
|
||||
image: BytesOrPIL, fmt: str = "PNG", optimize: bool = True,
|
||||
image: BytesOrPIL, fmt: str = "PNG", optimize: bool = True,
|
||||
) -> bytes:
|
||||
"""Compress image in a separate process, without blocking event loop."""
|
||||
"""Compress image in a separate process, without blocking event loop."""
|
||||
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
COMPRESSION_POOL, _compress, image, fmt, optimize,
|
||||
)
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
COMPRESSION_POOL, _compress, image, fmt, optimize,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,9 @@ TextEdit {
|
||||
focus: false
|
||||
selectByMouse: true
|
||||
|
||||
onLinkActivated: if (enableLinkActivation && link !== '#state-text')
|
||||
onLinkActivated: if (enableLinkActivation
|
||||
&& link !== '#state-text'
|
||||
&& link !== '#replaced-text')
|
||||
Qt.openUrlExternally(link)
|
||||
|
||||
MouseArea {
|
||||
|
||||
@@ -28,8 +28,17 @@ HPage {
|
||||
HTabButton { text: qsTr("Security") }
|
||||
}
|
||||
|
||||
General { userId: page.userId }
|
||||
Notifications { userId: page.userId }
|
||||
Security { userId: page.userId }
|
||||
General {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
Notifications {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
Security {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,8 @@ HFlickableColumnPage {
|
||||
// Layout.preferredWidth: 256 * theme.uiScale
|
||||
Layout.preferredHeight: width
|
||||
|
||||
HoverHandler { id: overlayHover }
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
z: 10
|
||||
@@ -154,8 +156,6 @@ HFlickableColumnPage {
|
||||
Behavior on opacity { HNumberAnimation {} }
|
||||
Behavior on color { HColorAnimation {} }
|
||||
|
||||
HoverHandler { id: overlayHover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: ready && account.presence !== "offline"
|
||||
|
||||
@@ -26,8 +26,17 @@ HPage {
|
||||
HTabButton { text: qsTr("Create group") }
|
||||
}
|
||||
|
||||
DirectChat { userId: page.userId }
|
||||
JoinRoom { userId: page.userId }
|
||||
CreateRoom { userId: page.userId }
|
||||
DirectChat {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
JoinRoom {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
CreateRoom {
|
||||
userId: page.userId
|
||||
implicitWidth: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,18 @@ HRowLayout {
|
||||
">"
|
||||
) + "</font></font></a>"
|
||||
|
||||
readonly property var reactions: model.reactions
|
||||
|
||||
readonly property var contentHistory: model.content_history
|
||||
readonly property string replacedText:
|
||||
`<a href="#replaced-text" style="text-decoration: none">` +
|
||||
`<font size=${theme.fontSize.small}px><font ` + (
|
||||
model.replaced ?
|
||||
`color="${theme.chat.message.readCounter}"> 🖉` : // U+1F589
|
||||
|
||||
">"
|
||||
) + "</font></font></a>"
|
||||
|
||||
readonly property bool pureMedia: ! contentText && linksRepeater.count
|
||||
|
||||
readonly property bool hoveredSelectable: contentHover.hovered
|
||||
@@ -123,6 +135,13 @@ HRowLayout {
|
||||
id: contentLabel
|
||||
visible: ! pureMedia
|
||||
enableLinkActivation: ! eventList.selectedCount
|
||||
onLinkActivated:
|
||||
if(link === "#replaced-text") window.makePopup(
|
||||
"Popups/MessageReplaceHistoryPopup.qml",
|
||||
{
|
||||
contentHistory: contentHistory
|
||||
},
|
||||
)
|
||||
|
||||
selectByMouse:
|
||||
eventList.selectedCount <= 1 &&
|
||||
@@ -163,6 +182,7 @@ HRowLayout {
|
||||
timeText +
|
||||
"</font>" +
|
||||
|
||||
replacedText +
|
||||
stateText
|
||||
|
||||
transform: Translate { x: xOffset }
|
||||
@@ -298,6 +318,8 @@ HRowLayout {
|
||||
|
||||
linksRepeater.summedWidth +
|
||||
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
|
||||
|
||||
reactionsRow.width
|
||||
)
|
||||
height: contentColumn.height
|
||||
radius: theme.chat.message.radius
|
||||
@@ -361,6 +383,94 @@ HRowLayout {
|
||||
Layout.preferredHeight: item ? item.height : -1
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: reactionsRow
|
||||
|
||||
spacing: 10
|
||||
bottomPadding: 7
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
Layout.alignment: onRight ? Qt.AlignRight : Qt.AlignLeft
|
||||
|
||||
Repeater {
|
||||
id: reactionsRepeater
|
||||
|
||||
model: {
|
||||
const reactions = Object.entries(
|
||||
JSON.parse(eventDelegate.currentModel.reactions));
|
||||
return reactions;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: reactionItem
|
||||
|
||||
required property var modelData
|
||||
readonly property var icon: modelData[0]
|
||||
readonly property var hint: modelData[1]["hint"]
|
||||
readonly property var users: modelData[1]["users"]
|
||||
|
||||
width: reactionContent.width
|
||||
height: theme.fontSize.normal + 10
|
||||
radius: width / 2
|
||||
color: theme.colors.strongBackground
|
||||
border.color: theme.colors.accentBackground
|
||||
border.width: 1
|
||||
|
||||
Row {
|
||||
id: reactionContent
|
||||
spacing: 5
|
||||
topPadding: 3
|
||||
leftPadding: 10
|
||||
rightPadding: 10
|
||||
Text {
|
||||
id: reactionIcon
|
||||
color: theme.colors.brightText
|
||||
font.pixelSize: theme.fontSize.normal
|
||||
font.family: theme.fontFamily.sans
|
||||
text: parent.parent.icon
|
||||
}
|
||||
Text {
|
||||
id: reactionCounter
|
||||
color: theme.colors.brightText
|
||||
font.pixelSize: theme.fontSize.normal
|
||||
font.family: theme.fontFamily.sans
|
||||
text: parent.parent.users.length
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: reactionItemMouseArea
|
||||
anchors.fill: parent
|
||||
onEntered: { reactionTooltip.visible = true }
|
||||
onExited: { reactionTooltip.visible = false }
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
HToolTip {
|
||||
id: reactionTooltip
|
||||
visible: false
|
||||
|
||||
label.textFormat: HLabel.StyledText
|
||||
text: {
|
||||
const members =
|
||||
ModelStore.get(chat.userId, chat.roomId, "members")
|
||||
|
||||
const lines = [parent.hint]
|
||||
for (const userId of parent.users) {
|
||||
const member = members.find(userId)
|
||||
|
||||
const by = utils.coloredNameHtml(
|
||||
member ? member.display_name: userId, userId,
|
||||
)
|
||||
lines.push(qsTr("%1").arg(by))
|
||||
}
|
||||
return lines.join("<br>")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
|
||||
@@ -72,20 +72,26 @@ HColumnLayout {
|
||||
eventList.toggleCheck(model.index)
|
||||
}
|
||||
|
||||
visible: !model.hidden
|
||||
width: eventList.width - eventList.leftMargin - eventList.rightMargin
|
||||
|
||||
// Needed because of eventList's MouseArea which steals the
|
||||
// HSelectableLabel's MouseArea hover events
|
||||
onCursorShapeChanged: eventList.cursorShape = cursorShape
|
||||
|
||||
Component.onCompleted: if (model.fetch_profile)
|
||||
fetchProfilesFutureId = py.callClientCoro(
|
||||
chat.userId,
|
||||
"get_event_profiles",
|
||||
[chat.roomId, model.id],
|
||||
// The if avoids segfault if eventDelegate is already destroyed
|
||||
() => { if (eventDelegate) fetchProfilesFutureId = "" }
|
||||
)
|
||||
Component.onCompleted: {
|
||||
if (model.fetch_profile)
|
||||
fetchProfilesFutureId = py.callClientCoro(
|
||||
chat.userId,
|
||||
"get_event_profiles",
|
||||
[chat.roomId, model.id],
|
||||
// The if avoids segfault if eventDelegate is already destroyed
|
||||
() => { if (eventDelegate) fetchProfilesFutureId = "" }
|
||||
)
|
||||
// Workaround for hiding messages of certain types
|
||||
if (!eventDelegate.visible)
|
||||
eventDelegate.height = 0
|
||||
}
|
||||
|
||||
Component.onDestruction:
|
||||
if (fetchProfilesFutureId) py.cancelCoro(fetchProfilesFutureId)
|
||||
|
||||
@@ -266,10 +266,28 @@ Rectangle {
|
||||
highlightRangeMode = previous
|
||||
}
|
||||
|
||||
function focusPreviousVisibleMessage() {
|
||||
incrementCurrentIndex()
|
||||
let lastIndex = -1
|
||||
while ( currentIndex != lastIndex && model.get(currentIndex).hidden ) {
|
||||
lastIndex = currentIndex
|
||||
incrementCurrentIndex()
|
||||
}
|
||||
}
|
||||
|
||||
function focusPreviousMessage() {
|
||||
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
|
||||
focusCenterMessage() :
|
||||
incrementCurrentIndex()
|
||||
focusPreviousVisibleMessage()
|
||||
}
|
||||
|
||||
function focusNextVisibleMessage() {
|
||||
decrementCurrentIndex()
|
||||
while ( currentIndex > -1 && model.get(currentIndex).hidden ) {
|
||||
if ( currentIndex === 0 )
|
||||
currentIndex = -1;
|
||||
decrementCurrentIndex()
|
||||
}
|
||||
}
|
||||
|
||||
function focusNextMessage() {
|
||||
@@ -279,7 +297,7 @@ Rectangle {
|
||||
eventList.currentIndex === 0 ?
|
||||
eventList.currentIndex = -1 :
|
||||
|
||||
decrementCurrentIndex()
|
||||
focusNextVisibleMessage()
|
||||
}
|
||||
|
||||
function copySelectedDelegates() {
|
||||
@@ -332,7 +350,7 @@ Rectangle {
|
||||
}
|
||||
|
||||
function canCombine(item, itemAfter) {
|
||||
if (! item || ! itemAfter) return false
|
||||
if (! item || ! itemAfter || item.hidden) return false
|
||||
|
||||
return Boolean(
|
||||
! canTalkBreak(item, itemAfter) &&
|
||||
|
||||
260
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal file
260
src/gui/Pages/Chat/Timeline/HistoryContent.qml
Normal file
@@ -0,0 +1,260 @@
|
||||
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import "../../../Base"
|
||||
import "../../.."
|
||||
|
||||
HRowLayout {
|
||||
id: historyContent
|
||||
|
||||
readonly property var mentions: []
|
||||
|
||||
readonly property string mentionsCSS: {
|
||||
const lines = []
|
||||
|
||||
for (const [name, link] of mentions) {
|
||||
if (! link.match(/^https?:\/\/matrix.to\/#\/@.+/)) continue
|
||||
|
||||
lines.push(
|
||||
`.mention[data-mention='${utils.escapeHtml(name)}'] ` +
|
||||
`{ color: ${utils.nameColor(name)} }`
|
||||
)
|
||||
}
|
||||
|
||||
return "<style type='text/css'>" + lines.join("\n") + "</style>"
|
||||
}
|
||||
readonly property string diffCSS: {
|
||||
const lines = [
|
||||
"del { background-color: #f8d7da; color: #721c24; text-decoration: line-through; }",
|
||||
"ins { background-color: #d4edda; color: #155724; text-decoration: underline; }",
|
||||
]
|
||||
return "<style type='text/css'>" + lines.join("\n") + "</style>"
|
||||
}
|
||||
|
||||
|
||||
readonly property string senderText: ""
|
||||
property string contentText: model.content_diff
|
||||
readonly property string timeText: utils.formatTime(model.date, false)
|
||||
|
||||
readonly property bool pureMedia: false
|
||||
|
||||
readonly property bool hoveredSelectable: contentHover.hovered
|
||||
readonly property string hoveredLink:
|
||||
linksRepeater.lastHovered && linksRepeater.lastHovered.hovered ?
|
||||
linksRepeater.lastHovered.mediaUrl :
|
||||
contentLabel.hoveredLink
|
||||
|
||||
readonly property alias contentLabel: contentLabel
|
||||
|
||||
readonly property int xOffset: 0
|
||||
|
||||
readonly property int maxMessageWidth:
|
||||
contentText.includes("<pre>") || contentText.includes("<table>") ?
|
||||
-1 :
|
||||
window.settings.Chat.max_messages_line_length < 0 ?
|
||||
-1 :
|
||||
Math.ceil(
|
||||
mainUI.fontMetrics.averageCharacterWidth *
|
||||
window.settings.Chat.max_messages_line_length
|
||||
)
|
||||
|
||||
readonly property alias selectedText: contentLabel.selectedPlainText
|
||||
|
||||
spacing: theme.chat.message.horizontalSpacing
|
||||
layoutDirection: Qt.LeftToRight
|
||||
|
||||
HColumnLayout {
|
||||
id: contentColumn
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
HSelectableLabel {
|
||||
id: contentLabel
|
||||
visible: ! pureMedia
|
||||
enableLinkActivation: ! historyList.selectedCount
|
||||
|
||||
selectByMouse:
|
||||
historyList.selectedCount <= 1 &&
|
||||
historyDelegate.checked &&
|
||||
textSelectionBlocker.point.scenePosition === Qt.point(0, 0)
|
||||
|
||||
topPadding: theme.chat.message.verticalSpacing
|
||||
bottomPadding: topPadding
|
||||
leftPadding: historyContent.spacing
|
||||
rightPadding: leftPadding
|
||||
|
||||
color: theme.chat.message.body
|
||||
|
||||
font.italic: false
|
||||
wrapMode: TextEdit.Wrap
|
||||
textFormat: Text.RichText
|
||||
text:
|
||||
// CSS
|
||||
theme.chat.message.styleInclude + mentionsCSS + diffCSS +
|
||||
|
||||
// Sender name & message body
|
||||
(
|
||||
compact && contentText.match(/^\s*<(p|h[1-6])>/) ?
|
||||
contentText.replace(
|
||||
/(^\s*<(p|h[1-6])>)/, "$1" + senderText,
|
||||
) :
|
||||
senderText + contentText
|
||||
) +
|
||||
|
||||
// Time
|
||||
// For some reason, if there's only one space,
|
||||
// times will be on their own lines most of the time.
|
||||
" " +
|
||||
`<font size=${theme.fontSize.small}px ` +
|
||||
`color=${theme.chat.message.date}>` +
|
||||
timeText +
|
||||
"</font>"
|
||||
|
||||
transform: Translate { x: xOffset }
|
||||
|
||||
Layout.maximumWidth: historyContent.maxMessageWidth
|
||||
Layout.fillWidth: true
|
||||
|
||||
onSelectedTextChanged: if (selectedPlainText) {
|
||||
historyList.delegateWithSelectedText = model.id
|
||||
historyList.selectedText = selectedPlainText
|
||||
} else if (historyList.delegateWithSelectedText === model.id) {
|
||||
historyList.delegateWithSelectedText = ""
|
||||
historyList.selectedText = ""
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: historyList
|
||||
onCheckedChanged: contentLabel.deselect()
|
||||
onDelegateWithSelectedTextChanged: {
|
||||
if (historyList.delegateWithSelectedText !== model.id)
|
||||
contentLabel.deselect()
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler { id: contentHover }
|
||||
|
||||
PointHandler {
|
||||
id: mousePointHandler
|
||||
|
||||
property bool checkedNow: false
|
||||
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedModifiers: Qt.NoModifier
|
||||
acceptedPointerTypes:
|
||||
PointerDevice.GenericPointer | PointerDevice.Eraser
|
||||
|
||||
onActiveChanged: {
|
||||
if (active &&
|
||||
! historyDelegate.checked &&
|
||||
(! parent.hoveredLink ||
|
||||
! parent.enableLinkActivation)) {
|
||||
|
||||
historyList.check(model.index)
|
||||
checkedNow = true
|
||||
}
|
||||
|
||||
if (! active && historyDelegate.checked) {
|
||||
checkedNow ?
|
||||
checkedNow = false :
|
||||
historyList.uncheck(model.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PointHandler {
|
||||
id: mouseShiftPointHandler
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedModifiers: Qt.ShiftModifier
|
||||
acceptedPointerTypes:
|
||||
PointerDevice.GenericPointer | PointerDevice.Eraser
|
||||
|
||||
onActiveChanged: {
|
||||
if (active &&
|
||||
! historyDelegate.checked &&
|
||||
(! parent.hoveredLink ||
|
||||
! parent.enableLinkActivation)) {
|
||||
|
||||
historyList.checkFromLastToHere(model.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
id: touchTapHandler
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
|
||||
onTapped:
|
||||
if (! parent.hoveredLink || ! parent.enableLinkActivation)
|
||||
historyDelegate.toggleChecked()
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
id: textSelectionBlocker
|
||||
acceptedPointerTypes: PointerDevice.Finger | PointerDevice.Pen
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: contentBackground
|
||||
width: Math.max(
|
||||
parent.paintedWidth +
|
||||
parent.leftPadding + parent.rightPadding,
|
||||
|
||||
linksRepeater.summedWidth +
|
||||
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
|
||||
)
|
||||
height: contentColumn.height
|
||||
radius: theme.chat.message.radius
|
||||
z: -100
|
||||
color: historyDelegate.checked &&
|
||||
! contentLabel.selectedPlainText &&
|
||||
! mousePointHandler.active &&
|
||||
! mouseShiftPointHandler.active ?
|
||||
theme.chat.message.checkedBackground :
|
||||
|
||||
theme.chat.message.background
|
||||
}
|
||||
}
|
||||
|
||||
HRepeater {
|
||||
id: linksRepeater
|
||||
|
||||
property EventMediaLoader lastHovered: null
|
||||
|
||||
model: {
|
||||
const links = historyDelegate.currentModel.links
|
||||
|
||||
if (historyDelegate.currentModel.media_url)
|
||||
links.push(historyDelegate.currentModel.media_url)
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
EventMediaLoader {
|
||||
singleMediaInfo: historyDelegate.currentModel
|
||||
mediaUrl: modelData
|
||||
showSender: pureMedia ? senderText : ""
|
||||
showDate: pureMedia ? timeText : ""
|
||||
showLocalEcho: pureMedia && (
|
||||
singleMediaInfo.is_local_echo ||
|
||||
singleMediaInfo.read_by_count
|
||||
) ? stateText : ""
|
||||
|
||||
transform: Translate { x: xOffset }
|
||||
|
||||
onHoveredChanged: if (hovered) linksRepeater.lastHovered = this
|
||||
|
||||
Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
|
||||
Layout.leftMargin: pureMedia ? 0 : historyContent.spacing
|
||||
Layout.rightMargin: pureMedia ? 0 : historyContent.spacing
|
||||
Layout.preferredWidth: item ? item.width : -1
|
||||
Layout.preferredHeight: item ? item.height : -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HSpacer {}
|
||||
}
|
||||
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal file
93
src/gui/Pages/Chat/Timeline/HistoryDelegate.qml
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import Clipboard 0.1
|
||||
import "../../.."
|
||||
import "../../../Base"
|
||||
|
||||
HColumnLayout {
|
||||
id: historyDelegate
|
||||
|
||||
// Remember timeline goes from newest message at index 0 to oldest
|
||||
readonly property var previousModel: historyList.model.get(model.index + 1)
|
||||
readonly property var nextModel: historyList.model.get(model.index - 1)
|
||||
readonly property QtObject currentModel: model
|
||||
|
||||
readonly property bool isFocused: model.index === historyList.currentIndex
|
||||
|
||||
readonly property bool compact: window.settings.General.compact
|
||||
readonly property bool checked: model.id in historyList.checked
|
||||
readonly property bool isOwn: true
|
||||
readonly property bool isRedacted: false
|
||||
readonly property bool onRight: ! historyList.ownEventsOnLeft && isOwn
|
||||
readonly property bool combine: false
|
||||
readonly property bool talkBreak: false
|
||||
readonly property bool dayBreak:
|
||||
model.index === 0 ? true : historyList.canDayBreak(previousModel, model)
|
||||
|
||||
readonly property bool hideNameLine: true
|
||||
|
||||
readonly property int cursorShape:
|
||||
historyContent.hoveredLink ? Qt.PointingHandCursor :
|
||||
historyContent.hoveredSelectable ? Qt.IBeamCursor :
|
||||
Qt.ArrowCursor
|
||||
|
||||
readonly property int separationSpacing: theme.spacing * (
|
||||
dayBreak ? 4 :
|
||||
talkBreak ? 6 :
|
||||
combine && compact ? 0.25 :
|
||||
combine ? 0.5 :
|
||||
compact ? 1 :
|
||||
2
|
||||
)
|
||||
|
||||
readonly property alias historyContent: historyContent
|
||||
|
||||
function toggleChecked() {
|
||||
historyList.toggleCheck(model.index)
|
||||
}
|
||||
|
||||
width: historyList.width - historyList.leftMargin - historyList.rightMargin
|
||||
|
||||
// Needed because of historyList's MouseArea which steals the
|
||||
// HSelectableLabel's MouseArea hover events
|
||||
onCursorShapeChanged: historyList.cursorShape = cursorShape
|
||||
|
||||
ListView.onRemove: historyList.uncheck(model.id)
|
||||
|
||||
DelegateTransitionFixer {}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
visible: model.index !== 0
|
||||
Layout.preferredHeight: separationSpacing
|
||||
}
|
||||
|
||||
DayBreak {
|
||||
visible: dayBreak
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.minimumWidth: parent.width
|
||||
Layout.bottomMargin: separationSpacing
|
||||
}
|
||||
|
||||
HistoryContent {
|
||||
id: historyContent
|
||||
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedModifiers: Qt.NoModifier
|
||||
onTapped: toggleChecked()
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
acceptedModifiers: Qt.ShiftModifier
|
||||
onTapped: historyList.checkFromLastToHere(model.index)
|
||||
}
|
||||
}
|
||||
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal file
206
src/gui/Pages/Chat/Timeline/HistoryList.qml
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import QtQuick.Window 2.12
|
||||
import Clipboard 0.1
|
||||
import "../../.."
|
||||
import "../../../Base"
|
||||
import "../../../PythonBridge"
|
||||
import "../../../ShortcutBundles"
|
||||
|
||||
Rectangle {
|
||||
|
||||
readonly property alias historyList: historyList
|
||||
|
||||
color: theme.chat.eventList.background
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.Keys.Messages.unfocus_or_deselect
|
||||
onActivated: {
|
||||
historyList.selectedCount ?
|
||||
historyList.checked = {} :
|
||||
historyList.currentIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.Keys.Messages.previous
|
||||
onActivated: historyList.focusPreviousMessage()
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.Keys.Messages.next
|
||||
onActivated: historyList.focusNextMessage()
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
active: historyList.currentItem
|
||||
sequences: window.settings.Keys.Messages.select
|
||||
onActivated: historyList.toggleCheck(historyList.currentIndex)
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
active: historyList.currentItem
|
||||
sequences: window.settings.Keys.Messages.select_until_here
|
||||
onActivated:
|
||||
historyList.checkFromLastToHere(historyList.currentIndex)
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.Keys.Messages.open_links_files
|
||||
onActivated: {
|
||||
const indice =
|
||||
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
|
||||
|
||||
for (const i of Array.from(indice).sort().reverse()) {
|
||||
const event = historyList.model.get(i)
|
||||
|
||||
for (const url of JSON.parse(event.links)) {
|
||||
utils.getLinkType(url) === Utils.Media.Image ?
|
||||
historyList.openImageViewer(event, url) :
|
||||
Qt.openUrlExternally(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.Keys.Messages.open_links_files_externally
|
||||
onActivated: {
|
||||
const indice =
|
||||
historyList.getFocusedOrSelectedOrLastMediaEvents(true)
|
||||
|
||||
for (const i of Array.from(indice).sort().reverse()) {
|
||||
const event = historyList.model.get(i)
|
||||
|
||||
for (const url of JSON.parse(event.links))
|
||||
Qt.openUrlExternally(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HListView {
|
||||
id: historyList
|
||||
|
||||
property bool ownEventsOnLeft: false
|
||||
|
||||
property string delegateWithSelectedText: ""
|
||||
property string selectedText: ""
|
||||
|
||||
property bool showFocusedSeenTooltips: false
|
||||
|
||||
property alias cursorShape: cursorShapeArea.cursorShape
|
||||
|
||||
function focusCenterMessage() {
|
||||
const previous = highlightRangeMode
|
||||
highlightRangeMode = HListView.NoHighlightRange
|
||||
currentIndex = indexAt(0, contentY + height / 2)
|
||||
highlightRangeMode = previous
|
||||
}
|
||||
|
||||
function focusPreviousMessage() {
|
||||
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
|
||||
focusCenterMessage() :
|
||||
incrementCurrentIndex()
|
||||
}
|
||||
|
||||
function focusNextMessage() {
|
||||
currentIndex === -1 && visibleEnd.y < contentHeight - height / 4 ?
|
||||
focusCenterMessage() :
|
||||
|
||||
historyList.currentIndex === 0 ?
|
||||
historyList.currentIndex = -1 :
|
||||
|
||||
decrementCurrentIndex()
|
||||
}
|
||||
|
||||
function copySelectedDelegates() {
|
||||
if (historyList.selectedText) {
|
||||
Clipboard.text = historyList.selectedText
|
||||
return
|
||||
}
|
||||
|
||||
if (! historyList.selectedCount && historyList.currentIndex !== -1) {
|
||||
const model = historyList.model.get(historyList.currentIndex)
|
||||
const source = JSON.parse(model.source)
|
||||
|
||||
Clipboard.text =
|
||||
model.media_http_url &&
|
||||
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
|
||||
model.media_http_url :
|
||||
|
||||
"body" in source ?
|
||||
source.body :
|
||||
|
||||
utils.stripHtmlTags(utils.processedEventText(model))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const contents = []
|
||||
|
||||
for (const model of historyList.getSortedChecked()) {
|
||||
const source = JSON.parse(model.source)
|
||||
|
||||
contents.push(
|
||||
model.media_http_url &&
|
||||
utils.isEmptyObject(JSON.parse(model.media_crypt_dict)) ?
|
||||
model.media_http_url :
|
||||
|
||||
"body" in source ?
|
||||
source.body :
|
||||
|
||||
utils.stripHtmlTags(utils.processedEventText(model))
|
||||
)
|
||||
}
|
||||
|
||||
Clipboard.text = contents.join("\n\n")
|
||||
}
|
||||
|
||||
function canDayBreak(item, itemAfter) {
|
||||
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
|
||||
return false
|
||||
|
||||
return item.date.getDate() !== itemAfter.date.getDate()
|
||||
}
|
||||
|
||||
function getFocusedOrSelectedOrLastMediaEvents(acceptLinks=false) {
|
||||
if (historyList.selectedCount) return historyList.checkedIndice
|
||||
if (historyList.currentIndex !== -1) return [historyList.currentIndex]
|
||||
|
||||
// Find most recent event that's a media or contains links
|
||||
for (let i = 0; i < historyList.model.count && i <= 1000; i++) {
|
||||
const ev = historyList.model.get(i)
|
||||
const links = JSON.parse(ev.links)
|
||||
|
||||
if (ev.media_url || (acceptLinks && links.length)) return [i]
|
||||
}
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
keyNavigationWraps: false
|
||||
leftMargin: theme.spacing
|
||||
rightMargin: theme.spacing
|
||||
topMargin: theme.spacing
|
||||
bottomMargin: theme.spacing
|
||||
|
||||
// model: ModelStore.get(chat.userRoomId[0], chat.userRoomId[1], "events")
|
||||
model: []
|
||||
delegate: HistoryDelegate {}
|
||||
|
||||
highlight: Rectangle {
|
||||
color: theme.chat.message.focusedHighlight
|
||||
opacity: theme.chat.message.focusedHighlightOpacity
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: cursorShapeArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal file
44
src/gui/Popups/MessageReplaceHistoryPopup.qml
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
|
||||
// SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Controls 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import "../Base"
|
||||
import "../Base/Buttons"
|
||||
import "../Pages/Chat/Timeline"
|
||||
|
||||
HColumnPopup {
|
||||
id: popup
|
||||
|
||||
contentWidthLimit:
|
||||
window.settings.Chat.max_messages_line_length < 0 ?
|
||||
theme.controls.popup.defaultWidth * 2 :
|
||||
Math.ceil(
|
||||
mainUI.fontMetrics.averageCharacterWidth *
|
||||
window.settings.Chat.max_messages_line_length
|
||||
)
|
||||
property var contentHistory
|
||||
|
||||
page.footer: AutoDirectionLayout {
|
||||
CancelButton {
|
||||
id: cancelButton
|
||||
onClicked: popup.close()
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: cancelButton.forceActiveFocus()
|
||||
|
||||
SummaryLabel {
|
||||
text: qsTr("Message History")
|
||||
textFormat: Text.StyledText
|
||||
}
|
||||
|
||||
HistoryList {
|
||||
id: historyList
|
||||
historyList.model: contentHistory
|
||||
height: 400
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ QtObject {
|
||||
property bool keyboardFlicking: false
|
||||
|
||||
readonly property var imageExtensions: [
|
||||
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
|
||||
"tiff", "webp", "svg",
|
||||
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
|
||||
"tiff", "webp", "svg",
|
||||
]
|
||||
|
||||
readonly property var videoExtensions: [
|
||||
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
|
||||
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
|
||||
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
|
||||
]
|
||||
|
||||
readonly property var audioExtensions: [
|
||||
@@ -214,6 +214,31 @@ QtObject {
|
||||
const unknownMsg = type === "RoomMessageUnknown"
|
||||
const sender = coloredNameHtml(ev.sender_name, ev.sender_id)
|
||||
|
||||
if (ev.type_specifier === "Reaction") {
|
||||
let name = coloredNameHtml(
|
||||
ev.sender_name, ev.sender_id, "", true,
|
||||
)
|
||||
let reaction = ev.content
|
||||
|
||||
return qsTr(
|
||||
`<font color="${theme.chat.message.noticeBody}">` +
|
||||
name + ": " + reaction +
|
||||
"</font>"
|
||||
)
|
||||
}
|
||||
if (ev.type_specifier === "ReactionRedaction") {
|
||||
let name = coloredNameHtml(
|
||||
ev.sender_name, ev.sender_id, "", true,
|
||||
)
|
||||
let reaction = ev.content
|
||||
|
||||
return qsTr(
|
||||
`<font color="${theme.chat.message.noticeBody}">` +
|
||||
name + " removed a reaction" +
|
||||
"</font>"
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "RoomMessageEmote")
|
||||
return ev.content.match(/^\s*<(p|h[1-6])>/) ?
|
||||
ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :
|
||||
|
||||
@@ -367,7 +367,7 @@ int main(int argc, char *argv[]) {
|
||||
QApplication::setOrganizationName("moment");
|
||||
QApplication::setApplicationName("moment");
|
||||
QApplication::setApplicationDisplayName("Moment");
|
||||
QApplication::setApplicationVersion("0.7.3");
|
||||
QApplication::setApplicationVersion("0.7.5");
|
||||
QApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
|
||||
|
||||
// app needs to be constructed before attempting to migrate
|
||||
|
||||
Submodule submodules/SortFilterProxyModel updated: 36befddf5d...5a930885b7
Submodule submodules/hsluv-c updated: 9e9be32d60...c0cb66d62f
Reference in New Issue
Block a user